274 lines
9.6 KiB
C#
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
|
|
}
|
|
});
|
|
}
|
|
}
|