Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Surface.FS.Tests/FileSurfaceManifestStoreTests.cs
StellaOps Bot 0ada1b583f save progress
2025-12-20 12:15:16 +02:00

274 lines
9.6 KiB
C#

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<FileSurfaceManifestStore>.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<string, string>
{
["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<SurfaceManifestArtifact>()
};
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<SurfaceManifestArtifact>()
};
var doc2 = new SurfaceManifestDocument
{
Tenant = "tenant-two",
Artifacts = Array.Empty<SurfaceManifestArtifact>()
};
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<byte> data, string? algorithmId = null)
=> SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> 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<string> 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<byte> data, string purpose)
=> ComputeHash(data);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data);
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashAsync(stream, null, cancellationToken);
public ValueTask<string> 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<byte> data, string purpose)
=> $"{GetHashPrefix(purpose)}{ComputeHashHex(data)}";
}
public async ValueTask DisposeAsync()
{
await Task.Run(() =>
{
try
{
if (_root.Exists)
{
_root.Delete(recursive: true);
}
}
catch
{
// ignored
}
});
}
}