using System.Text; using Ludarium.Application; using Ludarium.Domain; using Ludarium.Infrastructure; namespace Ludarium.IntegrationTests; public sealed class GameDataVaultFileStoreTests { [Fact] public async Task OpaqueRevisionIsHashedIdempotentDownloadableAndDeletable() { var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N")); try { var store = new GameDataVaultFileStore(directory); var gameId = Guid.NewGuid(); var entryId = Guid.NewGuid(); var revisionId = Guid.NewGuid(); var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE\0slot=1\0progress=42"); var writes = await Task.WhenAll( store.SaveAsync(gameId, entryId, revisionId, "slot-1.sav", new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None), store.SaveAsync(gameId, entryId, revisionId, "slot-1.sav", new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None)); var revision = writes[0]; var retried = writes[1]; Assert.Equal(revision.Sha256, retried.Sha256); Assert.Equal(64, revision.Sha256.Length); Assert.Single(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories)); var opened = Assert.IsType(await store.OpenAsync(revision, CancellationToken.None)); await using (opened.Content) { using var copy = new MemoryStream(); await opened.Content.CopyToAsync(copy); Assert.Equal(payload, copy.ToArray()); } var conflictingPayload = Enumerable.Repeat((byte)0x5A, payload.Length).ToArray(); await Assert.ThrowsAsync(() => store.SaveAsync(gameId, entryId, revisionId, "slot-1.sav", new MemoryStream(conflictingPayload), conflictingPayload.Length, "integration-test", CancellationToken.None)); Assert.True(store.Delete(revision)); Assert.Null(await store.OpenAsync(revision, CancellationToken.None)); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Theory] [InlineData("MZsynthetic")] [InlineData("#!/bin/sh")] public async Task ExecutableAndScriptSignaturesAreRejected(string value) { var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N")); try { var store = new GameDataVaultFileStore(directory); var payload = Encoding.ASCII.GetBytes(value); await Assert.ThrowsAsync(() => store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None)); Assert.Empty(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories)); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task SameLengthCorruptionFailsClosedOnOpen() { var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N")); try { var store = new GameDataVaultFileStore(directory); var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE-CONTENT"); var revision = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None); var path = Path.Combine(directory, revision.Location.Replace('/', Path.DirectorySeparatorChar)); await File.WriteAllBytesAsync(path, Enumerable.Repeat((byte)0x41, payload.Length).ToArray()); await Assert.ThrowsAsync(() => store.OpenAsync(revision, CancellationToken.None)); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task DeclaredOversizeAndNativeExecutableSignaturesAreRejected() { var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N")); try { var store = new GameDataVaultFileStore(directory); await Assert.ThrowsAsync(() => store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream([0x01]), GameDataVaultFileStore.MaximumBytes + 1, "integration-test", CancellationToken.None)); byte[][] signatures = [ [0x7F, 0x45, 0x4C, 0x46, 0x00], [0xFE, 0xED, 0xFA, 0xCF, 0x00], [0xCA, 0xFE, 0xBA, 0xBE, 0x00] ]; foreach (var signature in signatures) await Assert.ThrowsAsync(() => store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(signature), signature.Length, "integration-test", CancellationToken.None)); Assert.Empty(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories)); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task ReconciliationRemovesOnlyAppOwnedIncompleteAndOrphanRevisionFiles() { var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N")); try { var store = new GameDataVaultFileStore(directory); var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE-CONTENT"); var revision = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None); var orphan = Path.Combine(directory, "orphan", "revision.bin"); var uploading = Path.Combine(directory, "orphan", "revision.uploading"); var unrelated = Path.Combine(directory, "orphan", "operator-note.txt"); Directory.CreateDirectory(Path.GetDirectoryName(orphan)!); await File.WriteAllBytesAsync(orphan, [1, 2, 3]); await File.WriteAllBytesAsync(uploading, [4, 5]); await File.WriteAllTextAsync(unrelated, "retain"); var result = store.Reconcile(new HashSet(StringComparer.OrdinalIgnoreCase) { revision.Location }); Assert.Equal(1, result.RemovedUploads); Assert.Equal(1, result.RemovedOrphans); Assert.Equal(5, result.ReclaimedBytes); Assert.True(File.Exists(Path.Combine(directory, revision.Location.Replace('/', Path.DirectorySeparatorChar)))); Assert.True(File.Exists(unrelated)); Assert.False(File.Exists(orphan)); Assert.False(File.Exists(uploading)); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } }