up
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Tests;
|
||||
|
||||
public sealed class AttestationBundleVerifierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly AttestationBundleVerifier _verifier;
|
||||
|
||||
public AttestationBundleVerifierTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"attest-bundle-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_verifier = new AttestationBundleVerifier(NullLogger<AttestationBundleVerifier>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FileNotFound_ReturnsFileNotFoundCode()
|
||||
{
|
||||
var options = new AttestationBundleVerifyOptions(
|
||||
Path.Combine(_tempDir, "nonexistent.tgz"),
|
||||
Offline: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.FileNotFound, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidBundle_ReturnsSuccess()
|
||||
{
|
||||
var bundlePath = await CreateValidBundleAsync();
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.Success, result.ExitCode);
|
||||
Assert.Equal("verified", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidBundle_ReturnsMetadata()
|
||||
{
|
||||
var bundlePath = await CreateValidBundleAsync();
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.ExportId);
|
||||
Assert.NotNull(result.AttestationId);
|
||||
Assert.NotNull(result.RootHash);
|
||||
Assert.StartsWith("sha256:", result.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_CorruptedArchive_ReturnsFormatError()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, "corrupted.tgz");
|
||||
await File.WriteAllBytesAsync(bundlePath, Encoding.UTF8.GetBytes("not a valid tgz"));
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.FormatError, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ChecksumMismatch_ReturnsChecksumMismatchCode()
|
||||
{
|
||||
var bundlePath = await CreateBundleWithBadChecksumAsync();
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.ChecksumMismatch, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ExternalChecksumMismatch_ReturnsChecksumMismatchCode()
|
||||
{
|
||||
var bundlePath = await CreateValidBundleAsync();
|
||||
var checksumPath = bundlePath + ".sha256";
|
||||
await File.WriteAllTextAsync(checksumPath, "0000000000000000000000000000000000000000000000000000000000000000 " + Path.GetFileName(bundlePath));
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.ChecksumMismatch, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingTransparency_WhenNotOffline_ReturnsMissingTransparencyCode()
|
||||
{
|
||||
var bundlePath = await CreateBundleWithoutTransparencyAsync();
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(
|
||||
bundlePath,
|
||||
Offline: false,
|
||||
VerifyTransparency: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.MissingTransparency, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingTransparency_WhenOffline_ReturnsSuccess()
|
||||
{
|
||||
var bundlePath = await CreateBundleWithoutTransparencyAsync();
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(
|
||||
bundlePath,
|
||||
Offline: true,
|
||||
VerifyTransparency: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.Success, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingDssePayload_ReturnsSignatureFailure()
|
||||
{
|
||||
var bundlePath = await CreateBundleWithMissingDssePayloadAsync();
|
||||
|
||||
var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true);
|
||||
|
||||
var result = await _verifier.VerifyAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.SignatureFailure, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_ValidBundle_ReturnsSuccess()
|
||||
{
|
||||
var bundlePath = await CreateValidBundleAsync();
|
||||
|
||||
var options = new AttestationBundleImportOptions(
|
||||
bundlePath,
|
||||
Tenant: "test-tenant",
|
||||
Namespace: "test-namespace",
|
||||
Offline: true);
|
||||
|
||||
var result = await _verifier.ImportAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(AttestationBundleExitCodes.Success, result.ExitCode);
|
||||
Assert.Equal("imported", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_InvalidBundle_ReturnsVerificationFailed()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, "invalid.tgz");
|
||||
await File.WriteAllBytesAsync(bundlePath, Encoding.UTF8.GetBytes("not valid"));
|
||||
|
||||
var options = new AttestationBundleImportOptions(
|
||||
bundlePath,
|
||||
Tenant: "test-tenant",
|
||||
Offline: true);
|
||||
|
||||
var result = await _verifier.ImportAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("verification_failed", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_InheritsTenantFromMetadata()
|
||||
{
|
||||
var bundlePath = await CreateValidBundleAsync();
|
||||
|
||||
var options = new AttestationBundleImportOptions(
|
||||
bundlePath,
|
||||
Tenant: null, // Not specified
|
||||
Offline: true);
|
||||
|
||||
var result = await _verifier.ImportAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.TenantId); // Should come from bundle metadata
|
||||
}
|
||||
|
||||
private async Task<string> CreateValidBundleAsync()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"valid-bundle-{Guid.NewGuid():N}.tgz");
|
||||
var exportId = Guid.NewGuid().ToString("D");
|
||||
var attestationId = Guid.NewGuid().ToString("D");
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
|
||||
// Create statement JSON
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = "https://stellaops.io/attestations/vuln-scan/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new { name = "test-image:latest", digest = new Dictionary<string, string> { ["sha256"] = "abc123" } }
|
||||
},
|
||||
predicate = new { }
|
||||
};
|
||||
var statementJson = JsonSerializer.Serialize(statement);
|
||||
var statementBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
|
||||
// Create DSSE envelope
|
||||
var dsse = new
|
||||
{
|
||||
payloadType = "application/vnd.in-toto+json",
|
||||
payload = statementBase64,
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyid = "key-001", sig = "fake-signature-for-test" }
|
||||
}
|
||||
};
|
||||
var dsseJson = JsonSerializer.Serialize(dsse);
|
||||
|
||||
// Create metadata
|
||||
var metadata = new
|
||||
{
|
||||
version = "attestation-bundle/v1",
|
||||
exportId,
|
||||
attestationId,
|
||||
tenantId,
|
||||
createdAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
rootHash = "abc123def456",
|
||||
statementVersion = "v1"
|
||||
};
|
||||
var metadataJson = JsonSerializer.Serialize(metadata);
|
||||
|
||||
// Create transparency entries
|
||||
var transparencyNdjson = "{\"logIndex\":1,\"logId\":\"test\"}\n";
|
||||
|
||||
// Calculate checksums
|
||||
var dsseHash = ComputeHash(dsseJson);
|
||||
var statementHash = ComputeHash(statementJson);
|
||||
var metadataHash = ComputeHash(metadataJson);
|
||||
var transparencyHash = ComputeHash(transparencyNdjson);
|
||||
|
||||
var checksums = new StringBuilder();
|
||||
checksums.AppendLine("# Attestation bundle checksums (sha256)");
|
||||
checksums.AppendLine($"{dsseHash} attestation.dsse.json");
|
||||
checksums.AppendLine($"{metadataHash} metadata.json");
|
||||
checksums.AppendLine($"{statementHash} statement.json");
|
||||
checksums.AppendLine($"{transparencyHash} transparency.ndjson");
|
||||
var checksumsText = checksums.ToString();
|
||||
|
||||
// Create archive
|
||||
await using var fileStream = File.Create(bundlePath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize);
|
||||
await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax);
|
||||
|
||||
await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson);
|
||||
await WriteEntryAsync(tarWriter, "checksums.txt", checksumsText);
|
||||
await WriteEntryAsync(tarWriter, "metadata.json", metadataJson);
|
||||
await WriteEntryAsync(tarWriter, "statement.json", statementJson);
|
||||
await WriteEntryAsync(tarWriter, "transparency.ndjson", transparencyNdjson);
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private async Task<string> CreateBundleWithoutTransparencyAsync()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"no-transparency-{Guid.NewGuid():N}.tgz");
|
||||
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = "https://stellaops.io/attestations/vuln-scan/v1",
|
||||
subject = new[] { new { name = "test", digest = new Dictionary<string, string> { ["sha256"] = "abc" } } }
|
||||
};
|
||||
var statementJson = JsonSerializer.Serialize(statement);
|
||||
var statementBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
|
||||
var dsse = new
|
||||
{
|
||||
payloadType = "application/vnd.in-toto+json",
|
||||
payload = statementBase64,
|
||||
signatures = new[] { new { keyid = "key-001", sig = "fake-sig" } }
|
||||
};
|
||||
var dsseJson = JsonSerializer.Serialize(dsse);
|
||||
|
||||
var metadata = new
|
||||
{
|
||||
version = "attestation-bundle/v1",
|
||||
exportId = Guid.NewGuid().ToString("D"),
|
||||
attestationId = Guid.NewGuid().ToString("D"),
|
||||
tenantId = Guid.NewGuid().ToString("D"),
|
||||
rootHash = "abc123"
|
||||
};
|
||||
var metadataJson = JsonSerializer.Serialize(metadata);
|
||||
|
||||
var dsseHash = ComputeHash(dsseJson);
|
||||
var statementHash = ComputeHash(statementJson);
|
||||
var metadataHash = ComputeHash(metadataJson);
|
||||
|
||||
var checksums = $"# Checksums\n{dsseHash} attestation.dsse.json\n{metadataHash} metadata.json\n{statementHash} statement.json\n";
|
||||
|
||||
await using var fileStream = File.Create(bundlePath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize);
|
||||
await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax);
|
||||
|
||||
await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson);
|
||||
await WriteEntryAsync(tarWriter, "checksums.txt", checksums);
|
||||
await WriteEntryAsync(tarWriter, "metadata.json", metadataJson);
|
||||
await WriteEntryAsync(tarWriter, "statement.json", statementJson);
|
||||
// No transparency.ndjson
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private async Task<string> CreateBundleWithBadChecksumAsync()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"bad-checksum-{Guid.NewGuid():N}.tgz");
|
||||
|
||||
var dsseJson = "{\"payloadType\":\"test\",\"payload\":\"dGVzdA==\",\"signatures\":[{\"keyid\":\"k\",\"sig\":\"s\"}]}";
|
||||
var statementJson = "{\"_type\":\"test\"}";
|
||||
var metadataJson = "{\"version\":\"v1\"}";
|
||||
|
||||
// Intentionally wrong checksum
|
||||
var checksums = "0000000000000000000000000000000000000000000000000000000000000000 attestation.dsse.json\n";
|
||||
|
||||
await using var fileStream = File.Create(bundlePath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize);
|
||||
await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax);
|
||||
|
||||
await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson);
|
||||
await WriteEntryAsync(tarWriter, "checksums.txt", checksums);
|
||||
await WriteEntryAsync(tarWriter, "metadata.json", metadataJson);
|
||||
await WriteEntryAsync(tarWriter, "statement.json", statementJson);
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private async Task<string> CreateBundleWithMissingDssePayloadAsync()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"no-dsse-payload-{Guid.NewGuid():N}.tgz");
|
||||
|
||||
// DSSE without payload
|
||||
var dsseJson = "{\"payloadType\":\"test\",\"signatures\":[]}";
|
||||
var statementJson = "{\"_type\":\"test\"}";
|
||||
var metadataJson = "{\"version\":\"v1\"}";
|
||||
|
||||
var dsseHash = ComputeHash(dsseJson);
|
||||
var statementHash = ComputeHash(statementJson);
|
||||
var metadataHash = ComputeHash(metadataJson);
|
||||
var checksums = $"{dsseHash} attestation.dsse.json\n{metadataHash} metadata.json\n{statementHash} statement.json\n";
|
||||
|
||||
await using var fileStream = File.Create(bundlePath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize);
|
||||
await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax);
|
||||
|
||||
await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson);
|
||||
await WriteEntryAsync(tarWriter, "checksums.txt", checksums);
|
||||
await WriteEntryAsync(tarWriter, "metadata.json", metadataJson);
|
||||
await WriteEntryAsync(tarWriter, "statement.json", statementJson);
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private static async Task WriteEntryAsync(TarWriter writer, string name, string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var dataStream = new MemoryStream(bytes);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
|
||||
{
|
||||
DataStream = dataStream
|
||||
};
|
||||
await writer.WriteEntryAsync(entry);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
|
||||
public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly DevPortalBundleVerifier _verifier;
|
||||
|
||||
public DevPortalBundleVerifierTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"devportal-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_verifier = new DevPortalBundleVerifier(NullLogger<DevPortalBundleVerifier>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ReturnsSuccess_ForValidBundle()
|
||||
{
|
||||
var bundlePath = CreateValidBundle();
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("verified", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.Success, result.ExitCode);
|
||||
Assert.Equal("a1b2c3d4-e5f6-7890-abcd-ef1234567890", result.BundleId);
|
||||
Assert.NotNull(result.RootHash);
|
||||
Assert.True(result.RootHash!.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(1, result.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ReturnsUnexpected_WhenBundleNotFound()
|
||||
{
|
||||
var nonExistentPath = Path.Combine(_tempDir, "nonexistent.tgz");
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(nonExistentPath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.Unexpected, result.ExitCode);
|
||||
Assert.Contains("not found", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ReturnsChecksumMismatch_WhenSha256DoesNotMatch()
|
||||
{
|
||||
var bundlePath = CreateValidBundle();
|
||||
var sha256Path = bundlePath + ".sha256";
|
||||
|
||||
// Write incorrect hash
|
||||
await File.WriteAllTextAsync(sha256Path, "0000000000000000000000000000000000000000000000000000000000000000 bundle.tgz");
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.ChecksumMismatch, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_SucceedsWithoutSha256File()
|
||||
{
|
||||
var bundlePath = CreateValidBundle();
|
||||
|
||||
// Remove .sha256 file if exists
|
||||
var sha256Path = bundlePath + ".sha256";
|
||||
if (File.Exists(sha256Path))
|
||||
{
|
||||
File.Delete(sha256Path);
|
||||
}
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("verified", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.Success, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ReturnsTsaMissing_WhenOnlineAndNoTimestamp()
|
||||
{
|
||||
var bundlePath = CreateBundleWithoutTimestamp();
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: false, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.TsaMissing, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_DetectsPortableBundle()
|
||||
{
|
||||
var bundlePath = CreatePortableBundle();
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("verified", result.Status);
|
||||
Assert.True(result.Portable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_OutputsKeysSortedAlphabetically()
|
||||
{
|
||||
var result = new DevPortalBundleVerificationResult
|
||||
{
|
||||
Status = "verified",
|
||||
BundleId = "test-id",
|
||||
RootHash = "sha256:abc123",
|
||||
Entries = 3,
|
||||
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Portable = false,
|
||||
ExitCode = DevPortalVerifyExitCode.Success
|
||||
};
|
||||
|
||||
var json = result.ToJson();
|
||||
|
||||
// Keys should be in alphabetical order
|
||||
var keys = JsonDocument.Parse(json).RootElement.EnumerateObject()
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(sortedKeys, keys);
|
||||
}
|
||||
|
||||
private string CreateValidBundle()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"bundle-{Guid.NewGuid():N}.tgz");
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
tenantId = "00000000-0000-0000-0000-000000000001",
|
||||
kind = 2,
|
||||
createdAt = "2025-12-07T10:30:00Z",
|
||||
metadata = new Dictionary<string, string> { ["source"] = "test" },
|
||||
entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "sbom",
|
||||
canonicalPath = "sbom/cyclonedx.json",
|
||||
sha256 = new string('a', 64),
|
||||
sizeBytes = 1024,
|
||||
mediaType = "application/vnd.cyclonedx+json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = false });
|
||||
var manifestPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
|
||||
|
||||
var signature = new
|
||||
{
|
||||
payloadType = "application/vnd.stella.evidence.manifest+json",
|
||||
payload = manifestPayload,
|
||||
signature = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-signature")),
|
||||
keyId = "key-1",
|
||||
algorithm = "ES256",
|
||||
provider = "StellaOps",
|
||||
signedAt = "2025-12-07T10:30:05Z",
|
||||
timestampedAt = "2025-12-07T10:30:06Z",
|
||||
timestampAuthority = "https://freetsa.org/tsr",
|
||||
timestampToken = Convert.ToBase64String(Encoding.UTF8.GetBytes("tsa-token"))
|
||||
};
|
||||
|
||||
var bundleMetadata = new
|
||||
{
|
||||
bundleId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
tenantId = "00000000-0000-0000-0000-000000000001",
|
||||
kind = 2,
|
||||
status = 3,
|
||||
rootHash = new string('f', 64),
|
||||
storageKey = "evidence/bundle.tgz",
|
||||
createdAt = "2025-12-07T10:30:00Z",
|
||||
sealedAt = "2025-12-07T10:30:05Z"
|
||||
};
|
||||
|
||||
CreateTgzBundle(bundlePath, manifestJson, signature, bundleMetadata);
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private string CreateBundleWithoutTimestamp()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"bundle-no-tsa-{Guid.NewGuid():N}.tgz");
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
||||
tenantId = "00000000-0000-0000-0000-000000000001",
|
||||
kind = 2,
|
||||
createdAt = "2025-12-07T10:30:00Z",
|
||||
entries = Array.Empty<object>()
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest);
|
||||
var manifestPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
|
||||
|
||||
var signature = new
|
||||
{
|
||||
payloadType = "application/vnd.stella.evidence.manifest+json",
|
||||
payload = manifestPayload,
|
||||
signature = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-signature")),
|
||||
keyId = "key-1",
|
||||
algorithm = "ES256",
|
||||
provider = "StellaOps",
|
||||
signedAt = "2025-12-07T10:30:05Z"
|
||||
// No timestampedAt, timestampAuthority, timestampToken
|
||||
};
|
||||
|
||||
var bundleMetadata = new
|
||||
{
|
||||
bundleId = "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
||||
tenantId = "00000000-0000-0000-0000-000000000001",
|
||||
kind = 2,
|
||||
status = 3,
|
||||
rootHash = new string('e', 64),
|
||||
storageKey = "evidence/bundle.tgz",
|
||||
createdAt = "2025-12-07T10:30:00Z",
|
||||
sealedAt = "2025-12-07T10:30:05Z"
|
||||
};
|
||||
|
||||
CreateTgzBundle(bundlePath, manifestJson, signature, bundleMetadata);
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private string CreatePortableBundle()
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"portable-{Guid.NewGuid():N}.tgz");
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "c3d4e5f6-a7b8-9012-cdef-345678901234",
|
||||
kind = 1,
|
||||
createdAt = "2025-12-07T10:30:00Z",
|
||||
entries = Array.Empty<object>()
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest);
|
||||
var manifestPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
|
||||
|
||||
var signature = new
|
||||
{
|
||||
payloadType = "application/vnd.stella.evidence.manifest+json",
|
||||
payload = manifestPayload,
|
||||
signature = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-signature")),
|
||||
keyId = "key-1",
|
||||
algorithm = "ES256",
|
||||
provider = "StellaOps",
|
||||
signedAt = "2025-12-07T10:30:05Z",
|
||||
timestampedAt = "2025-12-07T10:30:06Z",
|
||||
timestampAuthority = "tsa.default",
|
||||
timestampToken = Convert.ToBase64String(Encoding.UTF8.GetBytes("tsa-token"))
|
||||
};
|
||||
|
||||
var bundleMetadata = new
|
||||
{
|
||||
bundleId = "c3d4e5f6-a7b8-9012-cdef-345678901234",
|
||||
kind = 1,
|
||||
status = 3,
|
||||
rootHash = new string('d', 64),
|
||||
createdAt = "2025-12-07T10:30:00Z",
|
||||
sealedAt = "2025-12-07T10:30:05Z",
|
||||
portableGeneratedAt = "2025-12-07T10:35:00Z" // Indicates portable bundle
|
||||
};
|
||||
|
||||
CreateTgzBundle(bundlePath, manifestJson, signature, bundleMetadata);
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private static void CreateTgzBundle(string bundlePath, string manifestJson, object signature, object bundleMetadata)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Optimal, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzipStream))
|
||||
{
|
||||
AddTarEntry(tarWriter, "manifest.json", manifestJson);
|
||||
AddTarEntry(tarWriter, "signature.json", JsonSerializer.Serialize(signature));
|
||||
AddTarEntry(tarWriter, "bundle.json", JsonSerializer.Serialize(bundleMetadata));
|
||||
AddTarEntry(tarWriter, "checksums.txt", $"# checksums\n{new string('f', 64)} sbom/cyclonedx.json\n");
|
||||
}
|
||||
|
||||
memoryStream.Position = 0;
|
||||
using var fileStream = File.Create(bundlePath);
|
||||
memoryStream.CopyTo(fileStream);
|
||||
}
|
||||
|
||||
private static void AddTarEntry(TarWriter writer, string name, string content)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
|
||||
{
|
||||
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
|
||||
ModificationTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user