using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Cryptography; using StellaOps.Scanner.Surface.FS; using Xunit; namespace StellaOps.Scanner.Surface.FS.Tests; public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable { private readonly DirectoryInfo _root; private readonly FileSurfaceManifestStore _store; public FileSurfaceManifestStoreTests() { _root = Directory.CreateTempSubdirectory("surface-fs-tests"); var cacheOptions = Options.Create(new SurfaceCacheOptions { RootDirectory = Path.Combine(_root.FullName, "cache") }); var manifestOptions = Options.Create(new SurfaceManifestStoreOptions { RootDirectory = Path.Combine(_root.FullName, "manifests"), Bucket = "test-bucket", Prefix = "manifests" }); _store = new FileSurfaceManifestStore( cacheOptions, manifestOptions, NullLogger.Instance, new TestCryptoHash()); } [Fact] public async Task PublishAsync_WritesManifestWithDeterministicDigest() { var doc = new SurfaceManifestDocument { Tenant = "acme", ImageDigest = "sha256:deadbeef", Artifacts = new[] { new SurfaceManifestArtifact { Kind = "layer", Uri = "cas://bucket/layer", Digest = "sha256:aaaa", MediaType = "application/json", Format = "json", Metadata = new Dictionary { ["z"] = "last", ["a"] = "first" } }, new SurfaceManifestArtifact { Kind = "entrytrace", Uri = "cas://bucket/entry", Digest = "sha256:bbbb", MediaType = "application/json", Format = "json" } } }; var result = await _store.PublishAsync(doc); Assert.StartsWith("sha256:", result.ManifestDigest, StringComparison.Ordinal); Assert.Equal(result.ManifestDigest, $"sha256:{result.ManifestUri.Split('/', StringSplitOptions.RemoveEmptyEntries).Last()[..^5]}"); Assert.NotNull(result.Document); Assert.True(File.Exists(GetManifestPath(result.ManifestDigest, "acme"))); // Metadata dictionary should be sorted to guarantee deterministic serialization var artifact = result.Document.Artifacts.Single(a => a.Kind == "layer"); Assert.Equal(new[] { "a", "z" }, artifact.Metadata!.Keys); } [Fact] public async Task TryGetByUriAsync_ReturnsPublishedManifest() { var doc = new SurfaceManifestDocument { Tenant = "acme", ScanId = "scan-123", Artifacts = Array.Empty() }; var publish = await _store.PublishAsync(doc); var retrieved = await _store.TryGetByUriAsync(publish.ManifestUri); Assert.NotNull(retrieved); Assert.Equal("acme", retrieved!.Tenant); Assert.Equal("scan-123", retrieved.ScanId); } [Fact] public async Task PublishAsync_NormalizesDeterminismMetadataAndAttestations() { var doc = new SurfaceManifestDocument { Tenant = "acme", DeterminismMerkleRoot = "ABCDEF", Determinism = new SurfaceDeterminismMetadata { MerkleRoot = "ABCDEF", RecipeDigest = "1234", CompositionRecipeUri = " cas://bucket/recipe.json " }, Artifacts = new[] { new SurfaceManifestArtifact { Kind = "layer.fragments", Uri = "cas://bucket/fragments.json", Digest = "sha256:bbbb", MediaType = "application/json", Format = "json", Attestations = new[] { new SurfaceManifestAttestation { Kind = "dsse", Digest = "sha256:dddd", Uri = "cas://attest/dsse.json" }, new SurfaceManifestAttestation { Kind = "dsse", Digest = "sha256:cccc", Uri = "cas://attest/other.json" } } }, new SurfaceManifestArtifact { Kind = "composition.recipe", Uri = "cas://bucket/recipe.json", Digest = "sha256:1234", MediaType = "application/json", Format = "composition.recipe" } } }; var result = await _store.PublishAsync(doc); Assert.Equal("abcdef", result.Document.DeterminismMerkleRoot); Assert.Equal("sha256:1234", result.Document.Determinism!.RecipeDigest); Assert.Equal("cas://bucket/recipe.json", result.Document.Determinism!.CompositionRecipeUri); var attestationOrder = result.Document.Artifacts .Single(a => a.Kind == "layer.fragments") .Attestations! .Select(a => a.Digest) .ToArray(); Assert.Equal(new[] { "sha256:cccc", "sha256:dddd" }, attestationOrder); Assert.Equal(result.Document.DeterminismMerkleRoot, result.DeterminismMerkleRoot); } [Fact] public async Task TryGetByDigestAsync_ReturnsManifestAcrossTenants() { var doc1 = new SurfaceManifestDocument { Tenant = "tenant-one", Artifacts = Array.Empty() }; var doc2 = new SurfaceManifestDocument { Tenant = "tenant-two", Artifacts = Array.Empty() }; var publish1 = await _store.PublishAsync(doc1); var publish2 = await _store.PublishAsync(doc2); var retrieved = await _store.TryGetByDigestAsync(publish2.ManifestDigest); Assert.NotNull(retrieved); Assert.Equal("tenant-two", retrieved!.Tenant); } private string GetManifestPath(string digest, string tenant) { var hex = digest["sha256:".Length..]; return Path.Combine( Path.Combine(_root.FullName, "manifests"), tenant, hex[..2], hex[2..4], $"{hex}.json"); } private sealed class TestCryptoHash : ICryptoHash { public byte[] ComputeHash(ReadOnlySpan data, string? algorithmId = null) => SHA256.HashData(data); public string ComputeHashHex(ReadOnlySpan data, string? algorithmId = null) => Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant(); public string ComputeHashBase64(ReadOnlySpan data, string? algorithmId = null) => Convert.ToBase64String(ComputeHash(data, algorithmId)); public async ValueTask ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) { await using var buffer = new MemoryStream(); await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); return SHA256.HashData(buffer.ToArray()); } public async ValueTask ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) { var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(hash).ToLowerInvariant(); } // Purpose-based methods (delegate to algorithm-based methods for test purposes) public byte[] ComputeHashForPurpose(ReadOnlySpan data, string purpose) => ComputeHash(data); public string ComputeHashHexForPurpose(ReadOnlySpan data, string purpose) => ComputeHashHex(data); public string ComputeHashBase64ForPurpose(ReadOnlySpan data, string purpose) => ComputeHashBase64(data); public ValueTask ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => ComputeHashAsync(stream, null, cancellationToken); public ValueTask ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => ComputeHashHexAsync(stream, null, cancellationToken); public string GetAlgorithmForPurpose(string purpose) => "SHA-256"; public string GetHashPrefix(string purpose) => "sha256:"; public string ComputePrefixedHashForPurpose(ReadOnlySpan data, string purpose) => $"{GetHashPrefix(purpose)}{ComputeHashHex(data)}"; } public async ValueTask DisposeAsync() { await Task.Run(() => { try { if (_root.Exists) { _root.Delete(recursive: true); } } catch { // ignored } }); } }