This commit is contained in:
StellaOps Bot
2025-12-07 22:49:53 +02:00
parent 11597679ed
commit 7c24ed96ee
204 changed files with 23313 additions and 1430 deletions

View File

@@ -159,7 +159,12 @@ public sealed class EvidenceBundlePackagingService
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
{
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
ModificationTime = FixedTimestamp
ModificationTime = FixedTimestamp,
// Determinism: fixed uid/gid/owner/group per bundle-packaging.md
Uid = 0,
Gid = 0,
UserName = string.Empty,
GroupName = string.Empty
};
var bytes = Encoding.UTF8.GetBytes(content);

View File

@@ -345,7 +345,12 @@ public sealed class EvidencePortableBundleService
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
{
Mode = mode == default ? DefaultFileMode : mode,
ModificationTime = FixedTimestamp
ModificationTime = FixedTimestamp,
// Determinism: fixed uid/gid/owner/group per bundle-packaging.md
Uid = 0,
Gid = 0,
UserName = string.Empty,
GroupName = string.Empty
};
var bytes = Encoding.UTF8.GetBytes(content);

View File

@@ -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;

View File

@@ -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;