Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,82 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Sbomer.BuildXPlugin;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using Xunit;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Attestation;
public sealed class AttestorClientTests
{
[Fact]
public async Task SendPlaceholderAsync_PostsJsonPayload()
{
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.Accepted));
using var httpClient = new HttpClient(handler);
var client = new AttestorClient(httpClient);
var document = BuildDescriptorDocument();
var attestorUri = new Uri("https://attestor.example.com/api/v1/provenance");
await client.SendPlaceholderAsync(attestorUri, document, CancellationToken.None);
Assert.NotNull(handler.CapturedRequest);
Assert.Equal(HttpMethod.Post, handler.CapturedRequest!.Method);
Assert.Equal(attestorUri, handler.CapturedRequest.RequestUri);
var content = await handler.CapturedRequest.Content!.ReadAsStringAsync();
var json = JsonDocument.Parse(content);
Assert.Equal(document.Subject.Digest, json.RootElement.GetProperty("imageDigest").GetString());
Assert.Equal(document.Artifact.Digest, json.RootElement.GetProperty("sbomDigest").GetString());
Assert.Equal(document.Provenance.ExpectedDsseSha256, json.RootElement.GetProperty("expectedDsseSha256").GetString());
}
[Fact]
public async Task SendPlaceholderAsync_ThrowsOnFailure()
{
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("invalid")
});
using var httpClient = new HttpClient(handler);
var client = new AttestorClient(httpClient);
var document = BuildDescriptorDocument();
var attestorUri = new Uri("https://attestor.example.com/api/v1/provenance");
await Assert.ThrowsAsync<BuildxPluginException>(() => client.SendPlaceholderAsync(attestorUri, document, CancellationToken.None));
}
private static DescriptorDocument BuildDescriptorDocument()
{
var subject = new DescriptorSubject("application/vnd.oci.image.manifest.v1+json", "sha256:img");
var artifact = new DescriptorArtifact("application/vnd.cyclonedx+json", "sha256:sbom", 42, new System.Collections.Generic.Dictionary<string, string>());
var provenance = new DescriptorProvenance("pending", "sha256:dsse", "nonce", "https://attestor.example.com/api/v1/provenance", "https://slsa.dev/provenance/v1");
var generatorMetadata = new DescriptorGeneratorMetadata("generator", "1.0.0");
var metadata = new System.Collections.Generic.Dictionary<string, string>();
return new DescriptorDocument("schema", DateTimeOffset.UtcNow, generatorMetadata, subject, artifact, provenance, metadata);
}
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly HttpResponseMessage response;
public RecordingHandler(HttpResponseMessage response)
{
this.response = response;
}
public HttpRequestMessage? CapturedRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CapturedRequest = request;
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,34 @@
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Cas;
public sealed class LocalCasClientTests
{
[Fact]
public async Task VerifyWriteAsync_WritesProbeObject()
{
await using var temp = new TempDirectory();
var client = new LocalCasClient(new LocalCasOptions
{
RootDirectory = temp.Path,
Algorithm = "sha256"
});
var result = await client.VerifyWriteAsync(CancellationToken.None);
Assert.Equal("sha256", result.Algorithm);
Assert.True(File.Exists(result.Path));
var bytes = await File.ReadAllBytesAsync(result.Path);
Assert.Equal("stellaops-buildx-probe"u8.ToArray(), bytes);
var expectedDigest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
Assert.Equal(expectedDigest, result.Digest);
}
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Descriptor;
public sealed class DescriptorGeneratorTests
{
[Fact]
public async Task CreateAsync_BuildsDeterministicDescriptor()
{
await using var temp = new TempDirectory();
var sbomPath = Path.Combine(temp.Path, "sample.cdx.json");
await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}");
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var request = new DescriptorRequest
{
ImageDigest = "sha256:0123456789abcdef",
SbomPath = sbomPath,
SbomMediaType = "application/vnd.cyclonedx+json",
SbomFormat = "cyclonedx-json",
SbomKind = "inventory",
SbomArtifactType = "application/vnd.stellaops.sbom.layer+json",
SubjectMediaType = "application/vnd.oci.image.manifest.v1+json",
GeneratorVersion = "1.2.3",
GeneratorName = "StellaOps.Scanner.Sbomer.BuildXPlugin",
LicenseId = "lic-123",
SbomName = "sample.cdx.json",
Repository = "git.stella-ops.org/stellaops",
BuildRef = "refs/heads/main",
AttestorUri = "https://attestor.local/api/v1/provenance"
}.Validate();
var document = await generator.CreateAsync(request, CancellationToken.None);
Assert.Equal(DescriptorGenerator.Schema, document.Schema);
Assert.Equal(fakeTime.GetUtcNow(), document.GeneratedAt);
Assert.Equal(request.ImageDigest, document.Subject.Digest);
Assert.Equal(request.SbomMediaType, document.Artifact.MediaType);
Assert.Equal(request.SbomName, document.Artifact.Annotations["org.opencontainers.image.title"]);
Assert.Equal("pending", document.Provenance.Status);
Assert.Equal(request.AttestorUri, document.Provenance.AttestorUri);
Assert.Equal(request.PredicateType, document.Provenance.PredicateType);
var expectedSbomDigest = ComputeSha256File(sbomPath);
Assert.Equal(expectedSbomDigest, document.Artifact.Digest);
Assert.Equal(expectedSbomDigest, document.Metadata["sbomDigest"]);
var expectedDsse = ComputeExpectedDsse(request.ImageDigest, expectedSbomDigest, document.Provenance.Nonce);
Assert.Equal(expectedDsse, document.Provenance.ExpectedDsseSha256);
Assert.Equal(expectedDsse, document.Artifact.Annotations["org.stellaops.provenance.dsse.sha256"]);
Assert.Equal(document.Provenance.Nonce, document.Artifact.Annotations["org.stellaops.provenance.nonce"]);
}
[Fact]
public async Task CreateAsync_RepeatedInvocationsReuseDeterministicNonce()
{
await using var temp = new TempDirectory();
var sbomPath = Path.Combine(temp.Path, "sample.cdx.json");
await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}");
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var request = new DescriptorRequest
{
ImageDigest = "sha256:0123456789abcdef",
SbomPath = sbomPath,
SbomMediaType = "application/vnd.cyclonedx+json",
SbomFormat = "cyclonedx-json",
SbomKind = "inventory",
SbomArtifactType = "application/vnd.stellaops.sbom.layer+json",
SubjectMediaType = "application/vnd.oci.image.manifest.v1+json",
GeneratorVersion = "1.2.3",
GeneratorName = "StellaOps.Scanner.Sbomer.BuildXPlugin",
LicenseId = "lic-123",
SbomName = "sample.cdx.json",
Repository = "git.stella-ops.org/stellaops",
BuildRef = "refs/heads/main",
AttestorUri = "https://attestor.local/api/v1/provenance"
}.Validate();
var first = await generator.CreateAsync(request, CancellationToken.None);
var second = await generator.CreateAsync(request, CancellationToken.None);
Assert.Equal(first.Provenance.Nonce, second.Provenance.Nonce);
Assert.Equal(first.Provenance.ExpectedDsseSha256, second.Provenance.ExpectedDsseSha256);
Assert.Equal(first.Artifact.Annotations["org.stellaops.provenance.nonce"], second.Artifact.Annotations["org.stellaops.provenance.nonce"]);
Assert.Equal(first.Artifact.Annotations["org.stellaops.provenance.dsse.sha256"], second.Artifact.Annotations["org.stellaops.provenance.dsse.sha256"]);
}
[Fact]
public async Task CreateAsync_MetadataDifferencesYieldDistinctNonce()
{
await using var temp = new TempDirectory();
var sbomPath = Path.Combine(temp.Path, "sample.cdx.json");
await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}");
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var baseline = new DescriptorRequest
{
ImageDigest = "sha256:0123456789abcdef",
SbomPath = sbomPath,
Repository = "git.stella-ops.org/stellaops",
BuildRef = "refs/heads/main"
}.Validate();
var variant = baseline with
{
BuildRef = "refs/heads/feature",
Repository = "git.stella-ops.org/stellaops/feature"
};
variant = variant.Validate();
var baselineDocument = await generator.CreateAsync(baseline, CancellationToken.None);
var variantDocument = await generator.CreateAsync(variant, CancellationToken.None);
Assert.NotEqual(baselineDocument.Provenance.Nonce, variantDocument.Provenance.Nonce);
Assert.NotEqual(baselineDocument.Provenance.ExpectedDsseSha256, variantDocument.Provenance.ExpectedDsseSha256);
}
private static string ComputeSha256File(string path)
{
using var stream = File.OpenRead(path);
var hash = SHA256.HashData(stream);
return $"sha256:{Convert.ToHexString(hash).ToLower(CultureInfo.InvariantCulture)}";
}
private static string ComputeExpectedDsse(string imageDigest, string sbomDigest, string nonce)
{
var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}";
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(payload), hash);
return $"sha256:{Convert.ToHexString(hash).ToLower(CultureInfo.InvariantCulture)}";
}
}

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Descriptor;
public sealed class DescriptorGoldenTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
[Fact]
public async Task DescriptorMatchesBaselineFixture()
{
await using var temp = new TempDirectory();
var sbomPath = Path.Combine(temp.Path, "sample.cdx.json");
await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}");
var request = new DescriptorRequest
{
ImageDigest = "sha256:0123456789abcdef",
SbomPath = sbomPath,
SbomMediaType = "application/vnd.cyclonedx+json",
SbomFormat = "cyclonedx-json",
SbomKind = "inventory",
SbomArtifactType = "application/vnd.stellaops.sbom.layer+json",
SubjectMediaType = "application/vnd.oci.image.manifest.v1+json",
GeneratorVersion = "1.2.3",
GeneratorName = "StellaOps.Scanner.Sbomer.BuildXPlugin",
LicenseId = "lic-123",
SbomName = "sample.cdx.json",
Repository = "git.stella-ops.org/stellaops",
BuildRef = "refs/heads/main",
AttestorUri = "https://attestor.local/api/v1/provenance"
}.Validate();
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var document = await generator.CreateAsync(request, CancellationToken.None);
var actualJson = JsonSerializer.Serialize(document, SerializerOptions);
var normalizedJson = NormalizeDescriptorJson(actualJson, Path.GetFileName(sbomPath));
var projectRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
var fixturePath = Path.Combine(projectRoot, "Fixtures", "descriptor.baseline.json");
var updateRequested = string.Equals(Environment.GetEnvironmentVariable("UPDATE_BUILDX_FIXTURES"), "1", StringComparison.OrdinalIgnoreCase);
if (updateRequested)
{
Directory.CreateDirectory(Path.GetDirectoryName(fixturePath)!);
await File.WriteAllTextAsync(fixturePath, normalizedJson);
return;
}
if (!File.Exists(fixturePath))
{
throw new InvalidOperationException($"Baseline fixture '{fixturePath}' is missing. Set UPDATE_BUILDX_FIXTURES=1 and re-run the tests to generate it.");
}
var baselineJson = await File.ReadAllTextAsync(fixturePath);
using var baselineDoc = JsonDocument.Parse(baselineJson);
using var actualDoc = JsonDocument.Parse(normalizedJson);
AssertJsonEquivalent(baselineDoc.RootElement, actualDoc.RootElement);
}
private static string NormalizeDescriptorJson(string json, string sbomFileName)
{
var node = JsonNode.Parse(json)?.AsObject()
?? throw new InvalidOperationException("Failed to parse descriptor JSON for normalization.");
if (node["metadata"] is JsonObject metadata)
{
metadata["sbomPath"] = sbomFileName;
}
return node.ToJsonString(SerializerOptions);
}
private static void AssertJsonEquivalent(JsonElement expected, JsonElement actual)
{
if (expected.ValueKind != actual.ValueKind)
{
throw new Xunit.Sdk.XunitException($"Value kind mismatch. Expected '{expected.ValueKind}' but found '{actual.ValueKind}'.");
}
switch (expected.ValueKind)
{
case JsonValueKind.Object:
var expectedProperties = expected.EnumerateObject().ToDictionary(p => p.Name, p => p.Value, StringComparer.Ordinal);
var actualProperties = actual.EnumerateObject().ToDictionary(p => p.Name, p => p.Value, StringComparer.Ordinal);
Assert.Equal(
expectedProperties.Keys.OrderBy(static name => name).ToArray(),
actualProperties.Keys.OrderBy(static name => name).ToArray());
foreach (var propertyName in expectedProperties.Keys)
{
AssertJsonEquivalent(expectedProperties[propertyName], actualProperties[propertyName]);
}
break;
case JsonValueKind.Array:
var expectedItems = expected.EnumerateArray().ToArray();
var actualItems = actual.EnumerateArray().ToArray();
Assert.Equal(expectedItems.Length, actualItems.Length);
for (var i = 0; i < expectedItems.Length; i++)
{
AssertJsonEquivalent(expectedItems[i], actualItems[i]);
}
break;
default:
Assert.Equal(expected.ToString(), actual.ToString());
break;
}
}
}

View File

@@ -0,0 +1,45 @@
{
"schema": "stellaops.buildx.descriptor.v1",
"generatedAt": "2025-10-18T12:00:00\u002B00:00",
"generator": {
"name": "StellaOps.Scanner.Sbomer.BuildXPlugin",
"version": "1.2.3"
},
"subject": {
"mediaType": "application/vnd.oci.image.manifest.v1\u002Bjson",
"digest": "sha256:0123456789abcdef"
},
"artifact": {
"mediaType": "application/vnd.cyclonedx\u002Bjson",
"digest": "sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c",
"size": 45,
"annotations": {
"org.opencontainers.artifact.type": "application/vnd.stellaops.sbom.layer\u002Bjson",
"org.stellaops.scanner.version": "1.2.3",
"org.stellaops.sbom.kind": "inventory",
"org.stellaops.sbom.format": "cyclonedx-json",
"org.stellaops.provenance.status": "pending",
"org.stellaops.provenance.dsse.sha256": "sha256:1b364a6b888d580feb8565f7b6195b24535ca8201b4bcac58da063b32c47220d",
"org.stellaops.provenance.nonce": "a608acf859cd58a8389816b8d9eb2a07",
"org.stellaops.license.id": "lic-123",
"org.opencontainers.image.title": "sample.cdx.json",
"org.stellaops.repository": "git.stella-ops.org/stellaops"
}
},
"provenance": {
"status": "pending",
"expectedDsseSha256": "sha256:1b364a6b888d580feb8565f7b6195b24535ca8201b4bcac58da063b32c47220d",
"nonce": "a608acf859cd58a8389816b8d9eb2a07",
"attestorUri": "https://attestor.local/api/v1/provenance",
"predicateType": "https://slsa.dev/provenance/v1"
},
"metadata": {
"sbomDigest": "sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c",
"sbomPath": "sample.cdx.json",
"sbomMediaType": "application/vnd.cyclonedx\u002Bjson",
"subjectMediaType": "application/vnd.oci.image.manifest.v1\u002Bjson",
"repository": "git.stella-ops.org/stellaops",
"buildRef": "refs/heads/main",
"attestorUri": "https://attestor.local/api/v1/provenance"
}
}

View File

@@ -0,0 +1,80 @@
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Sbomer.BuildXPlugin;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Manifest;
public sealed class BuildxPluginManifestLoaderTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
[Fact]
public async Task LoadAsync_ReturnsManifestWithSourceInformation()
{
await using var temp = new TempDirectory();
var manifestPath = System.IO.Path.Combine(temp.Path, "stellaops.manifest.json");
await File.WriteAllTextAsync(manifestPath, BuildSampleManifestJson("stellaops.sbom-indexer"));
var loader = new BuildxPluginManifestLoader(temp.Path);
var manifests = await loader.LoadAsync(CancellationToken.None);
var manifest = Assert.Single(manifests);
Assert.Equal("stellaops.sbom-indexer", manifest.Id);
Assert.Equal("0.1.0", manifest.Version);
Assert.Equal(manifestPath, manifest.SourcePath);
Assert.Equal(Path.GetDirectoryName(manifestPath), manifest.SourceDirectory);
}
[Fact]
public async Task LoadDefaultAsync_ThrowsWhenNoManifests()
{
await using var temp = new TempDirectory();
var loader = new BuildxPluginManifestLoader(temp.Path);
await Assert.ThrowsAsync<BuildxPluginException>(() => loader.LoadDefaultAsync(CancellationToken.None));
}
[Fact]
public async Task LoadAsync_ThrowsWhenRestartRequiredMissing()
{
await using var temp = new TempDirectory();
var manifestPath = Path.Combine(temp.Path, "failure.manifest.json");
await File.WriteAllTextAsync(manifestPath, BuildSampleManifestJson("stellaops.failure", requiresRestart: false));
var loader = new BuildxPluginManifestLoader(temp.Path);
await Assert.ThrowsAsync<BuildxPluginException>(() => loader.LoadAsync(CancellationToken.None));
}
private static string BuildSampleManifestJson(string id, bool requiresRestart = true)
{
var manifest = new BuildxPluginManifest
{
SchemaVersion = BuildxPluginManifest.CurrentSchemaVersion,
Id = id,
DisplayName = "Sample",
Version = "0.1.0",
RequiresRestart = requiresRestart,
EntryPoint = new BuildxPluginEntryPoint
{
Type = "dotnet",
Executable = "StellaOps.Scanner.Sbomer.BuildXPlugin.dll"
},
Cas = new BuildxPluginCas
{
Protocol = "filesystem",
DefaultRoot = "cas"
}
};
return JsonSerializer.Serialize(manifest, SerializerOptions);
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\descriptor.baseline.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,44 @@
using System;
using System.IO;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
internal sealed class TempDirectory : IDisposable, IAsyncDisposable
{
public string Path { get; }
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-buildx-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public void Dispose()
{
Cleanup();
GC.SuppressFinalize(this);
}
public ValueTask DisposeAsync()
{
Cleanup();
GC.SuppressFinalize(this);
return ValueTask.CompletedTask;
}
private void Cleanup()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// Best effort cleanup only.
}
}
}