up
This commit is contained in:
@@ -18,6 +18,11 @@ public sealed class EvidenceBundlePackagingServiceTests
|
||||
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 3, 12, 30, 0, TimeSpan.Zero);
|
||||
|
||||
// Fixed IDs for determinism tests (must be constant across runs)
|
||||
private static readonly EvidenceBundleId BundleIdForDeterminism = EvidenceBundleId.FromGuid(
|
||||
new Guid("11111111-2222-3333-4444-555555555555"));
|
||||
private static readonly DateTimeOffset CreatedAtForDeterminism = new(2025, 11, 10, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_ReturnsCached_WhenPackageExists()
|
||||
{
|
||||
@@ -105,6 +110,59 @@ public sealed class EvidenceBundlePackagingServiceTests
|
||||
Assert.Equal(expectedSeconds, mtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_ProducesDeterministicTarEntryMetadata()
|
||||
{
|
||||
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature());
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.True(objectStore.Stored);
|
||||
var entryMetadata = ReadArchiveEntryMetadata(objectStore.StoredBytes!);
|
||||
|
||||
// Verify all entries have deterministic uid/gid/username/groupname per bundle-packaging.md
|
||||
foreach (var (name, meta) in entryMetadata)
|
||||
{
|
||||
Assert.Equal(0, meta.Uid);
|
||||
Assert.Equal(0, meta.Gid);
|
||||
Assert.True(
|
||||
string.IsNullOrEmpty(meta.UserName),
|
||||
$"Entry '{name}' should have empty username but was '{meta.UserName}'");
|
||||
Assert.True(
|
||||
string.IsNullOrEmpty(meta.GroupName),
|
||||
$"Entry '{name}' should have empty groupname but was '{meta.GroupName}'");
|
||||
Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), meta.ModificationTime);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_ProducesIdenticalBytesForSameInput()
|
||||
{
|
||||
// First run
|
||||
var signature1 = CreateSignatureForDeterminism();
|
||||
var bundle1 = CreateSealedBundleForDeterminism();
|
||||
var repository1 = new FakeRepository(bundle1, signature1);
|
||||
var objectStore1 = new FakeObjectStore(exists: false);
|
||||
var service1 = new EvidenceBundlePackagingService(repository1, objectStore1, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
await service1.EnsurePackageAsync(TenantId, BundleIdForDeterminism, CancellationToken.None);
|
||||
|
||||
// Second run (same data)
|
||||
var signature2 = CreateSignatureForDeterminism();
|
||||
var bundle2 = CreateSealedBundleForDeterminism();
|
||||
var repository2 = new FakeRepository(bundle2, signature2);
|
||||
var objectStore2 = new FakeObjectStore(exists: false);
|
||||
var service2 = new EvidenceBundlePackagingService(repository2, objectStore2, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
await service2.EnsurePackageAsync(TenantId, BundleIdForDeterminism, CancellationToken.None);
|
||||
|
||||
Assert.True(objectStore1.Stored);
|
||||
Assert.True(objectStore2.Stored);
|
||||
Assert.Equal(objectStore1.StoredBytes, objectStore2.StoredBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_Throws_WhenManifestPayloadInvalid()
|
||||
{
|
||||
@@ -185,6 +243,62 @@ public sealed class EvidenceBundlePackagingServiceTests
|
||||
TimestampToken: includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
|
||||
}
|
||||
|
||||
// Determinism test helpers: fixed data for reproducible packaging
|
||||
private static EvidenceBundle CreateSealedBundleForDeterminism()
|
||||
=> new(
|
||||
BundleIdForDeterminism,
|
||||
TenantId,
|
||||
EvidenceBundleKind.Job,
|
||||
EvidenceBundleStatus.Sealed,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
$"tenants/{TenantId.Value:N}/bundles/{BundleIdForDeterminism.Value:N}/bundle.tgz",
|
||||
CreatedAtForDeterminism,
|
||||
CreatedAtForDeterminism,
|
||||
Description: "determinism test",
|
||||
SealedAt: CreatedAtForDeterminism.AddMinutes(1),
|
||||
ExpiresAt: null);
|
||||
|
||||
private static EvidenceBundleSignature CreateSignatureForDeterminism()
|
||||
{
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = BundleIdForDeterminism.Value.ToString("D"),
|
||||
tenantId = TenantId.Value.ToString("D"),
|
||||
kind = (int)EvidenceBundleKind.Job,
|
||||
createdAt = CreatedAtForDeterminism.ToString("O"),
|
||||
metadata = new Dictionary<string, string> { ["run"] = "determinism" },
|
||||
entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "inputs",
|
||||
canonicalPath = "inputs/config.json",
|
||||
sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
sizeBytes = 128,
|
||||
mediaType = "application/json",
|
||||
attributes = new Dictionary<string, string>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
|
||||
|
||||
return new EvidenceBundleSignature(
|
||||
BundleIdForDeterminism,
|
||||
TenantId,
|
||||
"application/vnd.stella.evidence.manifest+json",
|
||||
payload,
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes("fixed-signature")),
|
||||
"key-determinism",
|
||||
"ES256",
|
||||
"default",
|
||||
CreatedAtForDeterminism.AddMinutes(1),
|
||||
TimestampedAt: null,
|
||||
TimestampAuthority: null,
|
||||
TimestampToken: null);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
|
||||
{
|
||||
using var memory = new MemoryStream(archiveBytes);
|
||||
@@ -209,6 +323,39 @@ public sealed class EvidenceBundlePackagingServiceTests
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static Dictionary<string, TarEntryMetadata> ReadArchiveEntryMetadata(byte[] archiveBytes)
|
||||
{
|
||||
using var memory = new MemoryStream(archiveBytes);
|
||||
using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var reader = new TarReader(gzip);
|
||||
|
||||
var entries = new Dictionary<string, TarEntryMetadata>(StringComparer.Ordinal);
|
||||
TarEntry? entry;
|
||||
while ((entry = reader.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.EntryType != TarEntryType.RegularFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[entry.Name] = new TarEntryMetadata(
|
||||
entry.Uid,
|
||||
entry.Gid,
|
||||
entry.UserName ?? string.Empty,
|
||||
entry.GroupName ?? string.Empty,
|
||||
entry.ModificationTime);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed record TarEntryMetadata(
|
||||
int Uid,
|
||||
int Gid,
|
||||
string UserName,
|
||||
string GroupName,
|
||||
DateTimeOffset ModificationTime);
|
||||
|
||||
private sealed class FakeRepository : IEvidenceBundleRepository
|
||||
{
|
||||
private EvidenceBundle _bundle;
|
||||
|
||||
@@ -94,6 +94,33 @@ public sealed class EvidencePortableBundleServiceTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePortablePackageAsync_ProducesDeterministicTarEntryMetadata()
|
||||
{
|
||||
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = CreateService(repository, objectStore);
|
||||
|
||||
await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.True(objectStore.Stored);
|
||||
var entryMetadata = ReadArchiveEntryMetadata(objectStore.StoredBytes!);
|
||||
|
||||
// Verify all entries have deterministic uid/gid/username/groupname per bundle-packaging.md
|
||||
foreach (var (name, meta) in entryMetadata)
|
||||
{
|
||||
Assert.Equal(0, meta.Uid);
|
||||
Assert.Equal(0, meta.Gid);
|
||||
Assert.True(
|
||||
string.IsNullOrEmpty(meta.UserName),
|
||||
$"Entry '{name}' should have empty username but was '{meta.UserName}'");
|
||||
Assert.True(
|
||||
string.IsNullOrEmpty(meta.GroupName),
|
||||
$"Entry '{name}' should have empty groupname but was '{meta.GroupName}'");
|
||||
Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), meta.ModificationTime);
|
||||
}
|
||||
}
|
||||
|
||||
private static EvidencePortableBundleService CreateService(FakeRepository repository, IEvidenceObjectStore objectStore)
|
||||
{
|
||||
var options = Options.Create(new EvidenceLockerOptions
|
||||
@@ -200,6 +227,39 @@ public sealed class EvidencePortableBundleServiceTests
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static Dictionary<string, TarEntryMetadata> ReadArchiveEntryMetadata(byte[] archive)
|
||||
{
|
||||
using var memory = new MemoryStream(archive);
|
||||
using var gzip = new GZipStream(memory, CompressionMode.Decompress);
|
||||
using var tarReader = new TarReader(gzip);
|
||||
|
||||
var entries = new Dictionary<string, TarEntryMetadata>(StringComparer.Ordinal);
|
||||
TarEntry? entry;
|
||||
while ((entry = tarReader.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.EntryType != TarEntryType.RegularFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[entry.Name] = new TarEntryMetadata(
|
||||
entry.Uid,
|
||||
entry.Gid,
|
||||
entry.UserName ?? string.Empty,
|
||||
entry.GroupName ?? string.Empty,
|
||||
entry.ModificationTime);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed record TarEntryMetadata(
|
||||
int Uid,
|
||||
int Gid,
|
||||
string UserName,
|
||||
string GroupName,
|
||||
DateTimeOffset ModificationTime);
|
||||
|
||||
private sealed class FakeRepository : IEvidenceBundleRepository
|
||||
{
|
||||
private EvidenceBundle _bundle;
|
||||
|
||||
Reference in New Issue
Block a user