From 872d304779ace2b1adc079667c21570f04d84d5e Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Thu, 30 Jul 2026 13:50:09 +0100 Subject: [PATCH 1/7] feat(launching): record a launch receipt and revalidate cheaply before relaunch --- GenHub/GenHub.Core/Constants/FileTypes.cs | 5 + .../Launching/ILaunchReceiptService.cs | 29 ++ .../Models/Launching/LaunchReceipt.cs | 49 +++ .../Launching/LaunchReceiptArchiveRoot.cs | 17 + .../Models/Launching/LaunchReceiptContext.cs | 45 +++ .../Launching/LaunchReceiptDriftReport.cs | 22 ++ .../Launching/LaunchReceiptExecutable.cs | 19 ++ .../Features/Launching/GameLauncherTests.cs | 143 ++++++++- .../Launching/LaunchReceiptServiceTests.cs | 294 ++++++++++++++++++ .../GenHub/Features/Launching/GameLauncher.cs | 99 +++++- .../Launching/LaunchReceiptService.cs | 251 +++++++++++++++ .../GameLaunchingModule.cs | 3 + 12 files changed, 974 insertions(+), 2 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs create mode 100644 GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs create mode 100644 GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs create mode 100644 GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs create mode 100644 GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs create mode 100644 GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs create mode 100644 GenHub/GenHub/Features/Launching/LaunchReceiptService.cs diff --git a/GenHub/GenHub.Core/Constants/FileTypes.cs b/GenHub/GenHub.Core/Constants/FileTypes.cs index 0e1249755..02f7fc118 100644 --- a/GenHub/GenHub.Core/Constants/FileTypes.cs +++ b/GenHub/GenHub.Core/Constants/FileTypes.cs @@ -84,4 +84,9 @@ public static class FileTypes /// File extension for user data manifest files. /// public const string UserDataManifestExtension = ".userdata.json"; + + /// + /// File name of the launch receipt written into a workspace; the latest launch wins. + /// + public const string LaunchReceiptFileName = "launch-receipt.json"; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs b/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs new file mode 100644 index 000000000..52932e78c --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs @@ -0,0 +1,29 @@ +using GenHub.Core.Models.Launching; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Launching; + +/// +/// Records a receipt of what each launch consisted of and cheaply revalidates it before +/// subsequent launches so drift is detected without a full re-scan. +/// +public interface ILaunchReceiptService +{ + /// + /// Records a receipt for a launch into the workspace directory, replacing any previous one. + /// + /// What the launch consisted of. + /// A cancellation token to observe while waiting for the task to complete. + /// The recorded receipt, or a failure that must not block the launch. + Task> RecordLaunchAsync(LaunchReceiptContext context, CancellationToken cancellationToken = default); + + /// + /// Cheaply compares the receipt in a workspace, if one exists, against the current + /// on-disk state. Only existence, counts, sizes and timestamps are recomputed; nothing + /// is hashed. + /// + /// The workspace directory the receipt would live in. + /// A cancellation token to observe while waiting for the task to complete. + /// A drift report; an absent receipt yields an empty report, not a failure. + Task> RevalidateAsync(string workspacePath, CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs new file mode 100644 index 000000000..7f07bd14b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs @@ -0,0 +1,49 @@ +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Launching; + +/// +/// Record of what a launch consisted of, written into the workspace so subsequent launches +/// can cheaply detect drift and misbehaving launches have something to compare against. +/// +public class LaunchReceipt +{ + /// Gets or sets the receipt schema version. + public int SchemaVersion { get; set; } = 1; + + /// Gets or sets when the receipt was recorded, in UTC. + public DateTime RecordedAtUtc { get; set; } + + /// Gets or sets the launch identifier the receipt belongs to. + public string LaunchId { get; set; } = string.Empty; + + /// Gets or sets the profile that was launched. + public string ProfileId { get; set; } = string.Empty; + + /// Gets or sets the game client identifier, when the profile declared one. + public string? GameClientId { get; set; } + + /// Gets or sets the game that was launched. + public GameType GameType { get; set; } + + /// Gets or sets the workspace the launch ran from. + public string WorkspaceId { get; set; } = string.Empty; + + /// Gets or sets the working directory the process was started in. + public string WorkingDirectory { get; set; } = string.Empty; + + /// Gets or sets the fingerprint of the launched executable. + public LaunchReceiptExecutable Executable { get; set; } = new(); + + /// + /// Gets or sets the retail archive roots the engine was pointed at, keyed by the + /// environment variable that carried each root. + /// + public Dictionary ArchiveRoots { get; set; } = []; + + /// Gets or sets the manifest identifiers resolved for the launch. + public List ManifestIds { get; set; } = []; + + /// Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier. + public Dictionary ManifestVersions { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs new file mode 100644 index 000000000..b19a55fff --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs @@ -0,0 +1,17 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// Cheap fingerprint of one retail archive root: archive count and total bytes rather than +/// content hashes, so revalidation never rereads gigabytes of archives. +/// +public class LaunchReceiptArchiveRoot +{ + /// Gets or sets the archive root path. + public string Path { get; set; } = string.Empty; + + /// Gets or sets the number of archives in the root. + public int ArchiveCount { get; set; } + + /// Gets or sets the total size of the archives in the root, in bytes. + public long TotalArchiveBytes { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs new file mode 100644 index 000000000..966b2b1a9 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs @@ -0,0 +1,45 @@ +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Launching; + +/// +/// Everything a launch supplies for a receipt to be recorded from. +/// +public class LaunchReceiptContext +{ + /// Gets or sets the launch identifier. + public string LaunchId { get; set; } = string.Empty; + + /// Gets or sets the profile being launched. + public string ProfileId { get; set; } = string.Empty; + + /// Gets or sets the game client identifier, when the profile declares one. + public string? GameClientId { get; set; } + + /// Gets or sets the game being launched. + public GameType GameType { get; set; } + + /// Gets or sets the workspace the launch runs from. + public string WorkspaceId { get; set; } = string.Empty; + + /// Gets or sets the workspace directory the receipt is written into. + public string WorkspacePath { get; set; } = string.Empty; + + /// Gets or sets the executable being started. + public string ExecutablePath { get; set; } = string.Empty; + + /// Gets or sets the working directory the process is started in. + public string WorkingDirectory { get; set; } = string.Empty; + + /// + /// Gets or sets the environment passed to the child process; the retail archive root + /// variables are read from it. + /// + public IReadOnlyDictionary EnvironmentVariables { get; set; } = new Dictionary(); + + /// Gets or sets the manifest identifiers resolved for the launch. + public IReadOnlyList ManifestIds { get; set; } = []; + + /// Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier. + public IReadOnlyDictionary ManifestVersions { get; set; } = new Dictionary(); +} diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs new file mode 100644 index 000000000..77eb604f6 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// Outcome of cheaply revalidating a launch receipt against the current on-disk state. +/// +public class LaunchReceiptDriftReport +{ + /// Gets or sets the path the receipt was looked for at. + public string ReceiptPath { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether a receipt was present. An absent receipt is + /// not an error; there is simply nothing to compare against. + /// + public bool HasReceipt { get; set; } + + /// Gets or sets the description of each field that drifted since the receipt was recorded. + public List DriftedFields { get; set; } = []; + + /// Gets a value indicating whether any drift was detected. + public bool HasDrift => DriftedFields.Count > 0; +} diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs new file mode 100644 index 000000000..cedff3b40 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs @@ -0,0 +1,19 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// Fingerprint of the executable a launch started. +/// +public class LaunchReceiptExecutable +{ + /// Gets or sets the executable path. + public string Path { get; set; } = string.Empty; + + /// Gets or sets the executable size in bytes. + public long SizeBytes { get; set; } + + /// Gets or sets the executable's last write time, in UTC. + public DateTime LastWriteUtc { get; set; } + + /// Gets or sets the SHA-256 hash of the executable as a lowercase hex string. + public string Sha256 { get; set; } = string.Empty; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index 653652a44..24b552e18 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -45,6 +45,7 @@ public class GameLauncherTests : IDisposable private readonly Mock _storageLocationServiceMock = new(); private readonly Mock _profileContentLinkerMock = new(); private readonly Mock _steamLauncherMock = new(); + private readonly Mock _launchReceiptServiceMock = new(); private readonly GameLauncher _gameLauncher; private readonly string _retailRoot; @@ -117,6 +118,12 @@ public GameLauncherTests() _profileContentLinkerMock.Setup(x => x.GetActiveProfileId()) .Returns((string?)null); + // Setup launch receipt service mock + _launchReceiptServiceMock.Setup(x => x.RevalidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new LaunchReceiptDriftReport())); + _launchReceiptServiceMock.Setup(x => x.RecordLaunchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new LaunchReceipt())); + // Setup dependency resolver mock - returns resolved manifests including dependencies _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( It.IsAny>(), @@ -141,7 +148,8 @@ public GameLauncherTests() _gameSettingsServiceMock.Object, _profileContentLinkerMock.Object, _steamLauncherMock.Object, - _configurationProviderServiceMock.Object); + _configurationProviderServiceMock.Object, + _launchReceiptServiceMock.Object); } /// @@ -910,6 +918,139 @@ public async Task LaunchProfileAsync_WithoutProfileSettings_ShouldStillSaveOptio Times.Once); } + /// + /// Verifies a successful launch revalidates the previous receipt and records a new one. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithValidProfile_RecordsLaunchReceipt() + { + // Arrange + var profile = CreateTestProfile(); + var workspacePath = Path.Combine(_retailRoot, "workspace"); + var workspaceInfo = new WorkspaceInfo + { + Id = profile.Id, + WorkspacePath = workspacePath, + ExecutablePath = Path.Combine(workspacePath, "generalszh"), + }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content", Version = "1.0" }; + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success); + _launchReceiptServiceMock.Verify( + x => x.RevalidateAsync(It.IsAny(), It.IsAny()), + Times.Once); + _launchReceiptServiceMock.Verify( + x => x.RecordLaunchAsync( + It.Is(c => + c.ProfileId == profile.Id && + c.ExecutablePath == workspaceInfo.ExecutablePath && + c.WorkspacePath == workspaceInfo.WorkspacePath && + c.GameType == GameType.Generals && + c.ManifestIds.Contains("1.0.genhub.mod.test")), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies receipt drift is surfaced without blocking the launch. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithReceiptDrift_DoesNotBlockLaunch() + { + // Arrange + var profile = CreateTestProfile(); + var workspacePath = Path.Combine(_retailRoot, "workspace"); + var workspaceInfo = new WorkspaceInfo + { + Id = profile.Id, + WorkspacePath = workspacePath, + ExecutablePath = Path.Combine(workspacePath, "generalszh"), + }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + var driftReport = new LaunchReceiptDriftReport + { + HasReceipt = true, + DriftedFields = ["Executable size changed from 1 to 2 bytes: generals.exe"], + }; + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + _launchReceiptServiceMock.Setup(x => x.RevalidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(driftReport)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success); + } + + /// + /// Verifies a launch that has already started is not failed by a receipt-recording error. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WhenReceiptRecordingFails_StillSucceeds() + { + // Arrange + var profile = CreateTestProfile(); + var workspacePath = Path.Combine(_retailRoot, "workspace"); + var workspaceInfo = new WorkspaceInfo + { + Id = profile.Id, + WorkspacePath = workspacePath, + ExecutablePath = Path.Combine(workspacePath, "generalszh"), + }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + _launchReceiptServiceMock.Setup(x => x.RecordLaunchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("disk full")); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success); + } + /// /// Removes the temporary retail root. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs new file mode 100644 index 000000000..9434b95b1 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs @@ -0,0 +1,294 @@ +using System.Text.Json; +using GenHub.Common.Services; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Launching; +using GenHub.Features.Launching; +using Microsoft.Extensions.Logging; +using Moq; + +namespace GenHub.Tests.Core.Features.Launching; + +/// +/// Tests for . +/// +public class LaunchReceiptServiceTests : IDisposable +{ + private readonly LaunchReceiptService _service = new( + new Mock>().Object, + new Sha256HashProvider()); + + private readonly string _root; + private readonly string _workspacePath; + private readonly string _archiveRoot; + private readonly string _executablePath; + + /// + /// Initializes a new instance of the class. + /// + public LaunchReceiptServiceTests() + { + _root = Directory.CreateTempSubdirectory("GenHub.LaunchReceiptServiceTests.").FullName; + _workspacePath = Directory.CreateDirectory(Path.Combine(_root, "workspace")).FullName; + _archiveRoot = Directory.CreateDirectory(Path.Combine(_root, "retail")).FullName; + _executablePath = Path.Combine(_workspacePath, "generalszh"); + + File.WriteAllText(_executablePath, "executable bytes"); + File.WriteAllText(Path.Combine(_archiveRoot, "INIZH.big"), "archive one"); + File.WriteAllText(Path.Combine(_archiveRoot, "TexturesZH.big"), "archive two!"); + } + + /// + /// Recording writes a receipt into the workspace capturing the executable and archive roots. + /// + /// The async task. + [Fact] + public async Task RecordLaunchAsync_WritesReceiptIntoWorkspace() + { + var result = await _service.RecordLaunchAsync(CreateContext()); + + Assert.True(result.Success); + var receiptPath = Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName); + Assert.True(File.Exists(receiptPath)); + + var receipt = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(receiptPath), + new JsonSerializerOptions { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }); + Assert.NotNull(receipt); + Assert.Equal("profile-1", receipt.ProfileId); + Assert.Equal(GameType.ZeroHour, receipt.GameType); + Assert.Equal(_executablePath, receipt.Executable.Path); + Assert.Equal(new FileInfo(_executablePath).Length, receipt.Executable.SizeBytes); + Assert.NotEmpty(receipt.Executable.Sha256); + + var recordedRoot = Assert.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable, receipt.ArchiveRoots); + Assert.Equal(2, recordedRoot.ArchiveCount); + Assert.Equal( + new FileInfo(Path.Combine(_archiveRoot, "INIZH.big")).Length + + new FileInfo(Path.Combine(_archiveRoot, "TexturesZH.big")).Length, + recordedRoot.TotalArchiveBytes); + Assert.Contains("1.0.genhub.mod.test", receipt.ManifestIds); + } + + /// + /// A subsequent record replaces the previous receipt; the latest launch wins. + /// + /// The async task. + [Fact] + public async Task RecordLaunchAsync_ReplacesPreviousReceipt() + { + await _service.RecordLaunchAsync(CreateContext(launchId: "first")); + await _service.RecordLaunchAsync(CreateContext(launchId: "second")); + + var receipt = await ReadReceiptAsync(); + Assert.Equal("second", receipt.LaunchId); + } + + /// + /// Revalidating a workspace without a receipt is not an error and reports nothing. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithoutReceipt_ReportsNothing() + { + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.False(result.Data.HasReceipt); + Assert.False(result.Data.HasDrift); + } + + /// + /// Revalidating an unchanged state reports the receipt with no drift. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithUnchangedState_ReportsNoDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasReceipt); + Assert.False(result.Data.HasDrift); + } + + /// + /// An archive added to a root since the last launch is reported as count drift. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithChangedArchiveCount_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + File.WriteAllText(Path.Combine(_archiveRoot, "ModZH.big"), "a third archive"); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => + f.Contains("Archive count") && f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable)); + } + + /// + /// A mutated archive that keeps the count but changes total bytes is reported as drift. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithMutatedArchiveBytes_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + File.WriteAllText(Path.Combine(_archiveRoot, "INIZH.big"), "a much longer replacement archive body"); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => + f.Contains("Total archive bytes") && f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable)); + } + + /// + /// A root that disappeared since the last launch is reported as drift. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithMissingArchiveRoot_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + Directory.Delete(_archiveRoot, recursive: true); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => f.Contains("no longer exists") && f.Contains(_archiveRoot)); + } + + /// + /// A swapped executable is reported by its changed size without rehashing. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithSwappedExecutable_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + File.WriteAllText(_executablePath, "a different executable with a different length"); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => f.Contains("Executable size changed")); + } + + /// + /// A deleted executable is reported as drift rather than an error. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithMissingExecutable_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + File.Delete(_executablePath); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => f.Contains("Executable no longer exists")); + } + + /// + /// An unreadable receipt is reported as drift, not thrown. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithCorruptReceipt_ReportsDrift() + { + await File.WriteAllTextAsync(Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName), "{ not json"); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasReceipt); + Assert.True(result.Data.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => f.Contains("could not be read")); + } + + /// + /// Recording into a missing workspace fails without throwing. + /// + /// The async task. + [Fact] + public async Task RecordLaunchAsync_WithMissingWorkspace_ReturnsFailure() + { + var context = CreateContext(); + context.WorkspacePath = Path.Combine(_root, "does-not-exist"); + + var result = await _service.RecordLaunchAsync(context); + + Assert.False(result.Success); + Assert.Contains("Failed to record launch receipt", result.FirstError); + } + + /// + /// Removes the temporary directories. + /// + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // Best effort; a leftover temp directory is not worth failing the run over. + } + + GC.SuppressFinalize(this); + } + + /// + /// Reads the receipt back from the workspace. + /// + /// The deserialized receipt. + private async Task ReadReceiptAsync() + { + var json = await File.ReadAllTextAsync(Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName)); + var receipt = JsonSerializer.Deserialize( + json, + new JsonSerializerOptions { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }); + Assert.NotNull(receipt); + return receipt; + } + + /// + /// Creates a context pointing at the fixture workspace, executable and archive root. + /// + /// The launch identifier to record. + /// The context. + private LaunchReceiptContext CreateContext(string launchId = "launch-1") + { + return new LaunchReceiptContext + { + LaunchId = launchId, + ProfileId = "profile-1", + GameClientId = "client-1", + GameType = GameType.ZeroHour, + WorkspaceId = "profile-1", + WorkspacePath = _workspacePath, + ExecutablePath = _executablePath, + WorkingDirectory = _workspacePath, + EnvironmentVariables = new Dictionary + { + [RetailArchiveConstants.ZeroHourInstallPathVariable] = _archiveRoot + Path.DirectorySeparatorChar, + }, + ManifestIds = ["1.0.genhub.mod.test"], + ManifestVersions = new Dictionary { ["1.0.genhub.mod.test"] = "1.0" }, + }; + } +} diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 10e2c0fe7..c10ca75e2 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -21,6 +21,7 @@ using GenHub.Core.Interfaces.UserData; using GenHub.Core.Interfaces.Workspace; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameClients; using GenHub.Core.Models.GameInstallations; using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.GameSettings; @@ -49,7 +50,8 @@ public class GameLauncher( IGameSettingsService gameSettingsService, IProfileContentLinker profileContentLinker, ISteamLauncher steamLauncher, - IConfigurationProviderService configurationProvider) : IGameLauncher + IConfigurationProviderService configurationProvider, + ILaunchReceiptService launchReceiptService) : IGameLauncher { private static readonly ConcurrentDictionary _profileLaunchLocks = new(); private static readonly ConcurrentDictionary _steamInstallationLaunchLocks = @@ -922,6 +924,12 @@ private async Task> LaunchProfileAsync(Gam } } + // Revalidated before preparation because reconciliation removes files that are + // not in the manifests — the previous receipt does not survive it — and because + // drift since the last launch is what matters, not the effects of this one. + await RevalidateLaunchReceiptAsync( + Path.Combine(dynamicWorkspacePath, profile.Id), profile.Id, cancellationToken); + logger.LogInformation("[GameLauncher] Preparing workspace at: {WorkspacePath}", workspaceConfig.WorkspaceRootPath); var workspaceProgress = new Progress( wp => @@ -1298,6 +1306,8 @@ private async Task> LaunchProfileAsync(Gam var processInfo = processResult.Data; logger.LogInformation("[GameLauncher] Process started successfully - PID: {ProcessId}", processInfo.ProcessId); + await RecordLaunchReceiptAsync(profile, gameClient, workspaceInfo, launchConfig, manifests, launchId, cancellationToken); + // Update the placeholder launch entry with real process info // (The placeholder was registered earlier to prevent deletion during launch) var launchInfo = new GameLaunchInfo @@ -1336,6 +1346,93 @@ private async Task> LaunchProfileAsync(Gam } } + /// + /// Cheaply revalidates the previous launch receipt, if any, and logs a warning naming + /// each drifted field. Drift never blocks the launch; blocking on a misconfigured root + /// remains the job of . + /// + /// The workspace directory the receipt would live in. + /// The profile being launched. + /// A cancellation token to observe while waiting for the task to complete. + /// A task representing the asynchronous operation. + private async Task RevalidateLaunchReceiptAsync(string workspacePath, string profileId, CancellationToken cancellationToken) + { + var driftResult = await launchReceiptService.RevalidateAsync(workspacePath, cancellationToken); + if (!driftResult.Success) + { + logger.LogWarning("[GameLauncher] Launch receipt revalidation failed: {Error}", driftResult.FirstError); + return; + } + + if (driftResult.Data is not { HasReceipt: true } driftReport) + { + return; + } + + if (!driftReport.HasDrift) + { + logger.LogDebug("[GameLauncher] Launch receipt for profile {ProfileId} matches the current state", profileId); + return; + } + + foreach (var driftedField in driftReport.DriftedFields) + { + logger.LogWarning( + "[GameLauncher] Launch receipt drift for profile {ProfileId}: {DriftedField}", + profileId, + driftedField); + } + } + + /// + /// Records a receipt of what this launch consisted of into the workspace. A failure to + /// record is logged and never fails a launch that has already started. + /// + /// The launched profile. + /// The launched game client. + /// The prepared workspace. + /// The configuration the process was started with. + /// The manifests resolved for the launch. + /// The launch identifier. + /// A cancellation token to observe while waiting for the task to complete. + /// A task representing the asynchronous operation. + private async Task RecordLaunchReceiptAsync( + GameProfile profile, + GameClient gameClient, + WorkspaceInfo workspaceInfo, + GameLaunchConfiguration launchConfig, + IReadOnlyList manifests, + string launchId, + CancellationToken cancellationToken) + { + var manifestVersions = new Dictionary(); + foreach (var manifest in manifests) + { + manifestVersions[manifest.Id.Value] = manifest.Version; + } + + var receiptResult = await launchReceiptService.RecordLaunchAsync( + new LaunchReceiptContext + { + LaunchId = launchId, + ProfileId = profile.Id, + GameClientId = gameClient.Id, + GameType = gameClient.GameType, + WorkspaceId = workspaceInfo.Id, + WorkspacePath = workspaceInfo.WorkspacePath, + ExecutablePath = launchConfig.ExecutablePath, + WorkingDirectory = launchConfig.WorkingDirectory ?? workspaceInfo.WorkspacePath, + EnvironmentVariables = launchConfig.EnvironmentVariables, + ManifestIds = manifests.Select(m => m.Id.Value).ToList(), + ManifestVersions = manifestVersions, + }, + cancellationToken); + if (!receiptResult.Success) + { + logger.LogWarning("[GameLauncher] Failed to record launch receipt: {Error}", receiptResult.FirstError); + } + } + /// /// Applies the profile-specific game settings to the Options.ini file before launching. /// This ensures the game launches with the settings configured for this specific profile. diff --git a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs new file mode 100644 index 000000000..be99e4029 --- /dev/null +++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs @@ -0,0 +1,251 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Models.Launching; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Launching; + +/// +/// Records launch receipts into the workspace and cheaply revalidates them before +/// subsequent launches. +/// +/// +/// The receipt is a single JSON file beside the workspace content; the latest launch wins. +/// Recording hashes the executable once, but revalidation recomputes only cheap fields — +/// existence, counts, sizes and timestamps — so it can run on every launch. Archive roots +/// are fingerprinted by archive count and total bytes rather than content, because hashing +/// gigabytes of retail archives per launch would defeat the point. +/// +public class LaunchReceiptService( + ILogger logger, + IFileHashProvider hashProvider) : ILaunchReceiptService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + public async Task> RecordLaunchAsync(LaunchReceiptContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + try + { + var receipt = new LaunchReceipt + { + RecordedAtUtc = DateTime.UtcNow, + LaunchId = context.LaunchId, + ProfileId = context.ProfileId, + GameClientId = context.GameClientId, + GameType = context.GameType, + WorkspaceId = context.WorkspaceId, + WorkingDirectory = context.WorkingDirectory, + Executable = await FingerprintExecutableAsync(context.ExecutablePath, cancellationToken), + ManifestIds = [.. context.ManifestIds], + }; + + foreach (var (manifestId, version) in context.ManifestVersions) + { + receipt.ManifestVersions[manifestId] = version; + } + + foreach (var variableName in RetailArchiveConstants.InstallPathVariables) + { + if (context.EnvironmentVariables.TryGetValue(variableName, out var root) && + !string.IsNullOrWhiteSpace(root)) + { + receipt.ArchiveRoots[variableName] = FingerprintArchiveRoot(root); + } + } + + var receiptPath = GetReceiptPath(context.WorkspacePath); + var temporaryPath = receiptPath + ".tmp"; + await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(receipt, JsonOptions), cancellationToken); + File.Move(temporaryPath, receiptPath, overwrite: true); + + logger.LogDebug("Launch receipt recorded at {ReceiptPath}", receiptPath); + return OperationResult.CreateSuccess(receipt); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to record launch receipt for profile {ProfileId}", context.ProfileId); + return OperationResult.CreateFailure($"Failed to record launch receipt: {ex.Message}"); + } + } + + /// + public async Task> RevalidateAsync(string workspacePath, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + + var receiptPath = GetReceiptPath(workspacePath); + var report = new LaunchReceiptDriftReport { ReceiptPath = receiptPath }; + + if (!File.Exists(receiptPath)) + { + return OperationResult.CreateSuccess(report); + } + + report.HasReceipt = true; + + LaunchReceipt? receipt; + try + { + var json = await File.ReadAllTextAsync(receiptPath, cancellationToken); + receipt = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + report.DriftedFields.Add($"Receipt could not be read: {receiptPath} ({ex.Message})"); + return OperationResult.CreateSuccess(report); + } + + if (receipt is null) + { + report.DriftedFields.Add($"Receipt is empty: {receiptPath}"); + return OperationResult.CreateSuccess(report); + } + + CompareExecutable(receipt.Executable, report); + foreach (var (variableName, recordedRoot) in receipt.ArchiveRoots) + { + CompareArchiveRoot(variableName, recordedRoot, report); + } + + return OperationResult.CreateSuccess(report); + } + + /// + /// Resolves the receipt path within a workspace. + /// + /// The workspace directory. + /// The receipt path. + private static string GetReceiptPath(string workspacePath) => + Path.Combine(workspacePath, FileTypes.LaunchReceiptFileName); + + /// + /// Fingerprints one archive root by archive count and total bytes. + /// + /// The archive root. + /// The fingerprint; a missing root fingerprints as zero archives. + private static LaunchReceiptArchiveRoot FingerprintArchiveRoot(string rootPath) + { + var fingerprint = new LaunchReceiptArchiveRoot { Path = rootPath }; + if (!Directory.Exists(rootPath)) + { + return fingerprint; + } + + foreach (var archivePath in Directory.EnumerateFiles( + rootPath, RetailArchiveConstants.ArchiveSearchPattern, RetailArchiveConstants.ArchiveSearch)) + { + fingerprint.ArchiveCount++; + fingerprint.TotalArchiveBytes += new FileInfo(archivePath).Length; + } + + return fingerprint; + } + + /// + /// Compares the recorded executable fingerprint against the file on disk, by size and + /// last-write time only — the recorded hash is for after-the-fact comparison, not for + /// recomputation on every launch. + /// + /// The recorded fingerprint. + /// The report drifted fields are added to. + private static void CompareExecutable(LaunchReceiptExecutable recorded, LaunchReceiptDriftReport report) + { + if (string.IsNullOrEmpty(recorded.Path)) + { + return; + } + + var current = new FileInfo(recorded.Path); + if (!current.Exists) + { + report.DriftedFields.Add($"Executable no longer exists: {recorded.Path}"); + return; + } + + if (current.Length != recorded.SizeBytes) + { + report.DriftedFields.Add( + $"Executable size changed from {recorded.SizeBytes} to {current.Length} bytes: {recorded.Path}"); + } + + if (current.LastWriteTimeUtc != recorded.LastWriteUtc) + { + report.DriftedFields.Add( + $"Executable last-write time changed from {recorded.LastWriteUtc:O} to {current.LastWriteTimeUtc:O}: {recorded.Path}"); + } + } + + /// + /// Compares one recorded archive-root fingerprint against the directory on disk. + /// + /// The environment variable that carried the root. + /// The recorded fingerprint. + /// The report drifted fields are added to. + private static void CompareArchiveRoot(string variableName, LaunchReceiptArchiveRoot recorded, LaunchReceiptDriftReport report) + { + if (!Directory.Exists(recorded.Path)) + { + report.DriftedFields.Add($"Archive root for {variableName} no longer exists: {recorded.Path}"); + return; + } + + LaunchReceiptArchiveRoot current; + try + { + current = FingerprintArchiveRoot(recorded.Path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + report.DriftedFields.Add($"Archive root for {variableName} could not be read: {recorded.Path} ({ex.Message})"); + return; + } + + if (current.ArchiveCount != recorded.ArchiveCount) + { + report.DriftedFields.Add( + $"Archive count for {variableName} changed from {recorded.ArchiveCount} to {current.ArchiveCount}: {recorded.Path}"); + } + + if (current.TotalArchiveBytes != recorded.TotalArchiveBytes) + { + report.DriftedFields.Add( + $"Total archive bytes for {variableName} changed from {recorded.TotalArchiveBytes} to {current.TotalArchiveBytes}: {recorded.Path}"); + } + } + + /// + /// Fingerprints the executable, including the one hash recording pays for. + /// + /// The executable being launched. + /// A cancellation token to observe while waiting for the task to complete. + /// The fingerprint; a missing executable fingerprints as path only. + private async Task FingerprintExecutableAsync(string executablePath, CancellationToken cancellationToken) + { + var fingerprint = new LaunchReceiptExecutable { Path = executablePath }; + var info = new FileInfo(executablePath); + if (!info.Exists) + { + return fingerprint; + } + + fingerprint.SizeBytes = info.Length; + fingerprint.LastWriteUtc = info.LastWriteTimeUtc; + fingerprint.Sha256 = await hashProvider.ComputeFileHashAsync(executablePath, cancellationToken); + return fingerprint; + } +} diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs index 67a2dbb5e..4104c2bc5 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs @@ -27,6 +27,9 @@ public static IServiceCollection AddLaunchingServices(this IServiceCollection se // SteamLauncher for Steam integration - provisions files directly to game installation services.AddScoped(); + // Records a receipt per launch and cheaply revalidates it before subsequent launches + services.AddScoped(); + return services; } } From e4be1dc378e6a464ff5b525d471c01233f4fd9f2 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Thu, 30 Jul 2026 15:10:16 +0100 Subject: [PATCH 2/7] feat(launching): compare the upcoming launch configuration against the receipt --- .../Launching/ILaunchReceiptService.cs | 19 +++ .../Launching/LaunchReceiptDriftReport.cs | 7 + .../Features/Launching/GameLauncherTests.cs | 57 +++++++ .../Launching/LaunchReceiptServiceTests.cs | 156 +++++++++++++++++- .../GenHub/Features/Launching/GameLauncher.cs | 123 ++++++++------ .../Launching/LaunchReceiptService.cs | 128 ++++++++++++++ 6 files changed, 437 insertions(+), 53 deletions(-) diff --git a/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs b/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs index 52932e78c..bba9909ea 100644 --- a/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs +++ b/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs @@ -26,4 +26,23 @@ public interface ILaunchReceiptService /// A cancellation token to observe while waiting for the task to complete. /// A drift report; an absent receipt yields an empty report, not a failure. Task> RevalidateAsync(string workspacePath, CancellationToken cancellationToken = default); + + /// + /// Compares an upcoming launch's configuration against a previously recorded receipt: + /// game client, game type, executable path, manifest set and versions, and the archive + /// root paths about to be configured — the configuration itself, where + /// checks what is on disk. Touches no filesystem state. + /// + /// + /// A separate step because the two halves are known at different times: the receipt must + /// be read before workspace preparation rebuilds the workspace, while the upcoming + /// configuration — the resolved executable path in particular — exists only afterwards. + /// Profile identity is deliberately not compared: the receipt lives in the workspace and + /// the workspace is per-profile, so a mismatch cannot occur without the receipt being a + /// different file. + /// + /// The receipt from the previous launch. + /// The configuration of the launch about to happen. + /// A drift report naming each configuration field that changed. + LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, LaunchReceiptContext upcoming); } diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs index 77eb604f6..1d53def11 100644 --- a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs @@ -14,6 +14,13 @@ public class LaunchReceiptDriftReport /// public bool HasReceipt { get; set; } + /// + /// Gets or sets the parsed receipt when one was present and readable, so the upcoming + /// launch's configuration can be compared against it after the workspace — and the + /// receipt file with it — has been rebuilt. + /// + public LaunchReceipt? Receipt { get; set; } + /// Gets or sets the description of each field that drifted since the receipt was recorded. public List DriftedFields { get; set; } = []; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index 24b552e18..ca8ce2a29 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -123,6 +123,8 @@ public GameLauncherTests() .ReturnsAsync(OperationResult.CreateSuccess(new LaunchReceiptDriftReport())); _launchReceiptServiceMock.Setup(x => x.RecordLaunchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(OperationResult.CreateSuccess(new LaunchReceipt())); + _launchReceiptServiceMock.Setup(x => x.CompareUpcomingLaunch(It.IsAny(), It.IsAny())) + .Returns(new LaunchReceiptDriftReport { HasReceipt = true }); // Setup dependency resolver mock - returns resolved manifests including dependencies _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( @@ -1012,6 +1014,61 @@ public async Task LaunchProfileAsync_WithReceiptDrift_DoesNotBlockLaunch() Assert.True(result.Success); } + /// + /// Verifies a previous receipt is compared against the upcoming launch's configuration + /// once the launch configuration is built, without blocking the launch. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithPreviousReceipt_ComparesUpcomingConfiguration() + { + // Arrange + var profile = CreateTestProfile(); + var workspacePath = Path.Combine(_retailRoot, "workspace"); + var workspaceInfo = new WorkspaceInfo + { + Id = profile.Id, + WorkspacePath = workspacePath, + ExecutablePath = Path.Combine(workspacePath, "generalszh"), + }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + var previousReceipt = new LaunchReceipt { ProfileId = profile.Id, GameClientId = "old-client" }; + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + _launchReceiptServiceMock.Setup(x => x.RevalidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess( + new LaunchReceiptDriftReport { HasReceipt = true, Receipt = previousReceipt })); + _launchReceiptServiceMock.Setup(x => x.CompareUpcomingLaunch(previousReceipt, It.IsAny())) + .Returns(new LaunchReceiptDriftReport + { + HasReceipt = true, + DriftedFields = ["Game client changed from old-client to version-1"], + }); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success); + _launchReceiptServiceMock.Verify( + x => x.CompareUpcomingLaunch( + previousReceipt, + It.Is(c => + c.GameClientId == "version-1" && + c.ExecutablePath == workspaceInfo.ExecutablePath)), + Times.Once); + } + /// /// Verifies a launch that has already started is not failed by a receipt-recording error. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs index 9434b95b1..eef53d6b5 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs @@ -219,6 +219,138 @@ public async Task RevalidateAsync_WithCorruptReceipt_ReportsDrift() Assert.Contains(result.Data.DriftedFields, f => f.Contains("could not be read")); } + /// + /// An upcoming launch identical to the recorded one reports no configuration drift, and + /// revalidation hands back the parsed receipt for that comparison. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithIdenticalConfiguration_ReportsNoDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var revalidation = await _service.RevalidateAsync(_workspacePath); + Assert.NotNull(revalidation.Data!.Receipt); + + var report = _service.CompareUpcomingLaunch(revalidation.Data.Receipt!, CreateContext(launchId: "launch-2")); + + Assert.True(report.HasReceipt); + Assert.False(report.HasDrift); + } + + /// + /// A changed manifest version is reported as drift naming the manifest and both versions. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedManifestVersion_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var report = _service.CompareUpcomingLaunch(receipt, CreateContext(manifestVersion: "2.0")); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => + f.Contains("1.0.genhub.mod.test") && f.Contains("version changed from 1.0 to 2.0")); + } + + /// + /// A changed game client is reported as drift naming both clients. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedGameClient_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var report = _service.CompareUpcomingLaunch(receipt, CreateContext(gameClientId: "client-2")); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => f.Contains("Game client changed from client-1 to client-2")); + } + + /// + /// A changed game type is reported as drift. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedGameType_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var report = _service.CompareUpcomingLaunch(receipt, CreateContext(gameType: GameType.Generals)); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => f.Contains("Game type changed")); + } + + /// + /// A changed executable path is reported as drift even when the file itself is fine. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedExecutablePath_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var report = _service.CompareUpcomingLaunch( + receipt, CreateContext(executablePath: Path.Combine(_workspacePath, "otherclient"))); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => f.Contains("Executable path changed")); + } + + /// + /// A root moved to a different path with identical contents is reported as path drift; + /// the filesystem fingerprints alone could not tell the roots apart. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedRootPathAndIdenticalContents_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var relocatedRoot = Directory.CreateDirectory(Path.Combine(_root, "retail-copy")).FullName; + foreach (var archivePath in Directory.GetFiles(_archiveRoot, "*.big")) + { + File.Copy(archivePath, Path.Combine(relocatedRoot, Path.GetFileName(archivePath))); + } + + var report = _service.CompareUpcomingLaunch(receipt, CreateContext(archiveRoot: relocatedRoot)); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => + f.Contains("Archive root path") && + f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable) && + f.Contains(relocatedRoot)); + } + + /// + /// A changed manifest set is reported per added and removed manifest. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedManifestSet_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var upcoming = CreateContext(); + upcoming.ManifestIds = ["1.0.genhub.mod.other"]; + + var report = _service.CompareUpcomingLaunch(receipt, upcoming); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => + f.Contains("no longer part of the launch") && f.Contains("1.0.genhub.mod.test")); + Assert.Contains(report.DriftedFields, f => + f.Contains("added since the last launch") && f.Contains("1.0.genhub.mod.other")); + } + /// /// Recording into a missing workspace fails without throwing. /// @@ -270,25 +402,37 @@ private async Task ReadReceiptAsync() /// Creates a context pointing at the fixture workspace, executable and archive root. /// /// The launch identifier to record. + /// The game client identifier. + /// The game type. + /// The executable path; the fixture executable when null. + /// The archive root; the fixture root when null. + /// The version of the single fixture manifest. /// The context. - private LaunchReceiptContext CreateContext(string launchId = "launch-1") + private LaunchReceiptContext CreateContext( + string launchId = "launch-1", + string gameClientId = "client-1", + GameType gameType = GameType.ZeroHour, + string? executablePath = null, + string? archiveRoot = null, + string manifestVersion = "1.0") { return new LaunchReceiptContext { LaunchId = launchId, ProfileId = "profile-1", - GameClientId = "client-1", - GameType = GameType.ZeroHour, + GameClientId = gameClientId, + GameType = gameType, WorkspaceId = "profile-1", WorkspacePath = _workspacePath, - ExecutablePath = _executablePath, + ExecutablePath = executablePath ?? _executablePath, WorkingDirectory = _workspacePath, EnvironmentVariables = new Dictionary { - [RetailArchiveConstants.ZeroHourInstallPathVariable] = _archiveRoot + Path.DirectorySeparatorChar, + [RetailArchiveConstants.ZeroHourInstallPathVariable] = + (archiveRoot ?? _archiveRoot) + Path.DirectorySeparatorChar, }, ManifestIds = ["1.0.genhub.mod.test"], - ManifestVersions = new Dictionary { ["1.0.genhub.mod.test"] = "1.0" }, + ManifestVersions = new Dictionary { ["1.0.genhub.mod.test"] = manifestVersion }, }; } } diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index c10ca75e2..054f16336 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -705,6 +705,47 @@ private static void AddArchiveRoot( private static string EnsureTrailingSeparator(string path) => path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar; + /// + /// Builds the receipt context describing what this launch consists of, shared by the + /// configuration comparison before spawn and the recording after it. + /// + /// The profile being launched. + /// The game client being launched. + /// The prepared workspace. + /// The configuration the process is started with. + /// The manifests resolved for the launch. + /// The launch identifier. + /// The receipt context. + private static LaunchReceiptContext BuildLaunchReceiptContext( + GameProfile profile, + GameClient gameClient, + WorkspaceInfo workspaceInfo, + GameLaunchConfiguration launchConfig, + IReadOnlyList manifests, + string launchId) + { + var manifestVersions = new Dictionary(); + foreach (var manifest in manifests) + { + manifestVersions[manifest.Id.Value] = manifest.Version; + } + + return new LaunchReceiptContext + { + LaunchId = launchId, + ProfileId = profile.Id, + GameClientId = gameClient.Id, + GameType = gameClient.GameType, + WorkspaceId = workspaceInfo.Id, + WorkspacePath = workspaceInfo.WorkspacePath, + ExecutablePath = launchConfig.ExecutablePath, + WorkingDirectory = launchConfig.WorkingDirectory ?? workspaceInfo.WorkspacePath, + EnvironmentVariables = launchConfig.EnvironmentVariables, + ManifestIds = manifests.Select(m => m.Id.Value).ToList(), + ManifestVersions = manifestVersions, + }; + } + private async Task> LaunchProfileAsync(GameProfile profile, bool skipUserDataCleanup, IProgress? progress, string launchId, CancellationToken cancellationToken) { IDisposable? steamInstallationLock = null; @@ -926,8 +967,10 @@ private async Task> LaunchProfileAsync(Gam // Revalidated before preparation because reconciliation removes files that are // not in the manifests — the previous receipt does not survive it — and because - // drift since the last launch is what matters, not the effects of this one. - await RevalidateLaunchReceiptAsync( + // drift since the last launch is what matters, not the effects of this one. The + // parsed receipt is carried forward so the upcoming launch's configuration can + // be compared against it once it is fully built. + var previousReceipt = await RevalidateLaunchReceiptAsync( Path.Combine(dynamicWorkspacePath, profile.Id), profile.Id, cancellationToken); logger.LogInformation("[GameLauncher] Preparing workspace at: {WorkspacePath}", workspaceConfig.WorkspaceRootPath); @@ -1214,6 +1257,15 @@ await RevalidateLaunchReceiptAsync( return LaunchOperationResult.CreateFailure(archiveRootError, launchId, profile.Id); } + // The configuration half of receipt revalidation: only now are the resolved + // executable path and the child environment known, so this is the earliest the + // upcoming launch can be compared against the receipt read before preparation. + var receiptContext = BuildLaunchReceiptContext(profile, gameClient, workspaceInfo, launchConfig, manifests, launchId); + if (previousReceipt is not null) + { + LogReceiptDrift(profile.Id, launchReceiptService.CompareUpcomingLaunch(previousReceipt, receiptContext)); + } + logger.LogInformation("[GameLauncher] Starting game process..."); OperationResult processResult; @@ -1306,7 +1358,7 @@ await RevalidateLaunchReceiptAsync( var processInfo = processResult.Data; logger.LogInformation("[GameLauncher] Process started successfully - PID: {ProcessId}", processInfo.ProcessId); - await RecordLaunchReceiptAsync(profile, gameClient, workspaceInfo, launchConfig, manifests, launchId, cancellationToken); + await RecordLaunchReceiptAsync(receiptContext, cancellationToken); // Update the placeholder launch entry with real process info // (The placeholder was registered earlier to prevent deletion during launch) @@ -1347,34 +1399,44 @@ await RevalidateLaunchReceiptAsync( } /// - /// Cheaply revalidates the previous launch receipt, if any, and logs a warning naming - /// each drifted field. Drift never blocks the launch; blocking on a misconfigured root - /// remains the job of . + /// Cheaply revalidates the previous launch receipt against the filesystem, if any, and + /// logs a warning naming each drifted field. Drift never blocks the launch; blocking on + /// a misconfigured root remains the job of . /// /// The workspace directory the receipt would live in. /// The profile being launched. /// A cancellation token to observe while waiting for the task to complete. - /// A task representing the asynchronous operation. - private async Task RevalidateLaunchReceiptAsync(string workspacePath, string profileId, CancellationToken cancellationToken) + /// The parsed receipt, when one was present and readable, for the later configuration comparison. + private async Task RevalidateLaunchReceiptAsync(string workspacePath, string profileId, CancellationToken cancellationToken) { var driftResult = await launchReceiptService.RevalidateAsync(workspacePath, cancellationToken); if (!driftResult.Success) { logger.LogWarning("[GameLauncher] Launch receipt revalidation failed: {Error}", driftResult.FirstError); - return; + return null; } if (driftResult.Data is not { HasReceipt: true } driftReport) { - return; + return null; } if (!driftReport.HasDrift) { logger.LogDebug("[GameLauncher] Launch receipt for profile {ProfileId} matches the current state", profileId); - return; } + LogReceiptDrift(profileId, driftReport); + return driftReport.Receipt; + } + + /// + /// Logs a structured warning per drifted field. + /// + /// The profile being launched. + /// The report to log. + private void LogReceiptDrift(string profileId, LaunchReceiptDriftReport driftReport) + { foreach (var driftedField in driftReport.DriftedFields) { logger.LogWarning( @@ -1388,45 +1450,12 @@ private async Task RevalidateLaunchReceiptAsync(string workspacePath, string pro /// Records a receipt of what this launch consisted of into the workspace. A failure to /// record is logged and never fails a launch that has already started. /// - /// The launched profile. - /// The launched game client. - /// The prepared workspace. - /// The configuration the process was started with. - /// The manifests resolved for the launch. - /// The launch identifier. + /// What the launch consisted of. /// A cancellation token to observe while waiting for the task to complete. /// A task representing the asynchronous operation. - private async Task RecordLaunchReceiptAsync( - GameProfile profile, - GameClient gameClient, - WorkspaceInfo workspaceInfo, - GameLaunchConfiguration launchConfig, - IReadOnlyList manifests, - string launchId, - CancellationToken cancellationToken) + private async Task RecordLaunchReceiptAsync(LaunchReceiptContext receiptContext, CancellationToken cancellationToken) { - var manifestVersions = new Dictionary(); - foreach (var manifest in manifests) - { - manifestVersions[manifest.Id.Value] = manifest.Version; - } - - var receiptResult = await launchReceiptService.RecordLaunchAsync( - new LaunchReceiptContext - { - LaunchId = launchId, - ProfileId = profile.Id, - GameClientId = gameClient.Id, - GameType = gameClient.GameType, - WorkspaceId = workspaceInfo.Id, - WorkspacePath = workspaceInfo.WorkspacePath, - ExecutablePath = launchConfig.ExecutablePath, - WorkingDirectory = launchConfig.WorkingDirectory ?? workspaceInfo.WorkspacePath, - EnvironmentVariables = launchConfig.EnvironmentVariables, - ManifestIds = manifests.Select(m => m.Id.Value).ToList(), - ManifestVersions = manifestVersions, - }, - cancellationToken); + var receiptResult = await launchReceiptService.RecordLaunchAsync(receiptContext, cancellationToken); if (!receiptResult.Success) { logger.LogWarning("[GameLauncher] Failed to record launch receipt: {Error}", receiptResult.FirstError); diff --git a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs index be99e4029..beb8bb1bb 100644 --- a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs +++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; @@ -116,6 +117,7 @@ public async Task> RevalidateAsync(str return OperationResult.CreateSuccess(report); } + report.Receipt = receipt; CompareExecutable(receipt.Executable, report); foreach (var (variableName, recordedRoot) in receipt.ArchiveRoots) { @@ -125,6 +127,38 @@ public async Task> RevalidateAsync(str return OperationResult.CreateSuccess(report); } + /// + public LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, LaunchReceiptContext upcoming) + { + ArgumentNullException.ThrowIfNull(receipt); + ArgumentNullException.ThrowIfNull(upcoming); + + var report = new LaunchReceiptDriftReport { HasReceipt = true, Receipt = receipt }; + + if (!string.Equals(receipt.GameClientId ?? string.Empty, upcoming.GameClientId ?? string.Empty, StringComparison.Ordinal)) + { + report.DriftedFields.Add( + $"Game client changed from {receipt.GameClientId ?? "(none)"} to {upcoming.GameClientId ?? "(none)"}"); + } + + if (receipt.GameType != upcoming.GameType) + { + report.DriftedFields.Add($"Game type changed from {receipt.GameType} to {upcoming.GameType}"); + } + + if (!string.IsNullOrEmpty(upcoming.ExecutablePath) && + !PathsEqual(receipt.Executable.Path, upcoming.ExecutablePath)) + { + report.DriftedFields.Add( + $"Executable path changed from {receipt.Executable.Path} to {upcoming.ExecutablePath}"); + } + + CompareManifests(receipt, upcoming, report); + CompareArchiveRootConfiguration(receipt, upcoming, report); + + return report; + } + /// /// Resolves the receipt path within a workspace. /// @@ -133,6 +167,100 @@ public async Task> RevalidateAsync(str private static string GetReceiptPath(string workspacePath) => Path.Combine(workspacePath, FileTypes.LaunchReceiptFileName); + /// + /// Compares two paths ignoring a trailing directory separator, which the archive root + /// variables carry by engine requirement and other paths do not. + /// + /// One path. + /// The other path. + /// Whether the paths are equal. + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(left), + Path.TrimEndingDirectorySeparator(right), + StringComparison.Ordinal); + + /// + /// Compares the recorded manifest set and versions against the upcoming launch's. + /// + /// The receipt from the previous launch. + /// The configuration of the launch about to happen. + /// The report drifted fields are added to. + private static void CompareManifests(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report) + { + var recordedIds = new HashSet(receipt.ManifestIds, StringComparer.Ordinal); + var upcomingIds = new HashSet(upcoming.ManifestIds, StringComparer.Ordinal); + + foreach (var manifestId in receipt.ManifestIds) + { + if (!upcomingIds.Contains(manifestId)) + { + report.DriftedFields.Add($"Manifest no longer part of the launch: {manifestId}"); + } + } + + foreach (var manifestId in upcoming.ManifestIds) + { + if (!recordedIds.Contains(manifestId)) + { + report.DriftedFields.Add($"Manifest added since the last launch: {manifestId}"); + } + } + + foreach (var (manifestId, recordedVersion) in receipt.ManifestVersions) + { + if (upcoming.ManifestVersions.TryGetValue(manifestId, out var upcomingVersion) && + !string.Equals(recordedVersion, upcomingVersion, StringComparison.Ordinal)) + { + report.DriftedFields.Add( + $"Manifest {manifestId} version changed from {recordedVersion} to {upcomingVersion}"); + } + } + } + + /// + /// Compares which archive roots are configured, and where they point, against the + /// receipt — path changes, not content changes, which the filesystem checks own. + /// + /// The receipt from the previous launch. + /// The configuration of the launch about to happen. + /// The report drifted fields are added to. + private static void CompareArchiveRootConfiguration(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report) + { + foreach (var variableName in RetailArchiveConstants.InstallPathVariables) + { + receipt.ArchiveRoots.TryGetValue(variableName, out var recordedRoot); + var upcomingRoot = + upcoming.EnvironmentVariables.TryGetValue(variableName, out var configured) && + !string.IsNullOrWhiteSpace(configured) + ? configured + : null; + + if (recordedRoot is null && upcomingRoot is null) + { + continue; + } + + if (recordedRoot is null) + { + report.DriftedFields.Add($"Archive root for {variableName} newly configured: {upcomingRoot}"); + continue; + } + + if (upcomingRoot is null) + { + report.DriftedFields.Add($"Archive root for {variableName} no longer configured; was {recordedRoot.Path}"); + continue; + } + + if (!PathsEqual(recordedRoot.Path, upcomingRoot)) + { + report.DriftedFields.Add( + $"Archive root path for {variableName} changed from {recordedRoot.Path} to {upcomingRoot}"); + } + } + } + /// /// Fingerprints one archive root by archive count and total bytes. /// From 4f2fda499b60df98e3317ec2823ad3fb12bcbf42 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Thu, 30 Jul 2026 18:02:31 +0100 Subject: [PATCH 3/7] feat(launching): fingerprint archives individually and record variant, environment and result-surfaced drift --- .../Models/GameProfile/GameLaunchInfo.cs | 7 + .../Models/Launching/LaunchReceipt.cs | 18 ++ .../Launching/LaunchReceiptArchiveEntry.cs | 18 ++ .../Launching/LaunchReceiptArchiveRoot.cs | 12 +- .../Models/Launching/LaunchReceiptContext.cs | 10 +- .../Models/Launching/LaunchReceiptVariant.cs | 30 +++ .../Features/Launching/GameLauncherTests.cs | 21 ++- .../Launching/LaunchReceiptServiceTests.cs | 171 ++++++++++++++++- .../GenHub/Features/Launching/GameLauncher.cs | 47 ++++- .../Launching/LaunchReceiptService.cs | 175 +++++++++++++++++- 10 files changed, 482 insertions(+), 27 deletions(-) create mode 100644 GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs create mode 100644 GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs diff --git a/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs b/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs index 2800efed2..86d9bd7fd 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs @@ -23,6 +23,13 @@ public class GameLaunchInfo /// Gets or sets the termination timestamp. public DateTime? TerminatedAt { get; set; } + /// + /// Gets or sets the launch receipt drift detected before this launch, one warning per + /// drifted field, so a UI can show it. Informational only — drift never blocks a + /// launch — and empty when no receipt existed or nothing drifted. + /// + public List ReceiptDriftWarnings { get; set; } = []; + /// Gets a value indicating whether the game is still running. public bool IsRunning => TerminatedAt == null; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs index 7f07bd14b..2b78742f9 100644 --- a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs @@ -41,6 +41,24 @@ public class LaunchReceipt /// public Dictionary ArchiveRoots { get; set; } = []; + /// + /// Gets or sets the environment variables GenHub itself set for the child process: the + /// built launch environment — retail archive roots plus any profile-defined variables. + /// The inherited process environment is deliberately not recorded; it is large, differs + /// between hosts without meaning anything for the launch, and can carry secrets that a + /// receipt on disk must never capture. + /// + public Dictionary EnvironmentVariables { get; set; } = []; + + /// + /// Gets or sets the resolved variant and entry-point identity that determined what was + /// launched. Null when the profile carried no game client manifest: the legacy fallback + /// resolves the executable by filename search and no variant machinery participates, so + /// there is no variant identity to record. Populated whenever a game client manifest is + /// part of the launch, which is what workspace preparation resolves the entry point from. + /// + public LaunchReceiptVariant? Variant { get; set; } + /// Gets or sets the manifest identifiers resolved for the launch. public List ManifestIds { get; set; } = []; diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs new file mode 100644 index 000000000..39909ad46 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs @@ -0,0 +1,18 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// Cheap fingerprint of one archive within a retail root: name, size and timestamp, never +/// content. An equal-size replacement is visible through the timestamp where a count and +/// byte total alone could not see it. +/// +public class LaunchReceiptArchiveEntry +{ + /// Gets or sets the archive file name, without its directory. + public string FileName { get; set; } = string.Empty; + + /// Gets or sets the archive size in bytes. + public long SizeBytes { get; set; } + + /// Gets or sets the archive's last write time, in UTC. + public DateTime LastWriteUtc { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs index b19a55fff..b8fcb64cd 100644 --- a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs @@ -1,17 +1,21 @@ namespace GenHub.Core.Models.Launching; /// -/// Cheap fingerprint of one retail archive root: archive count and total bytes rather than -/// content hashes, so revalidation never rereads gigabytes of archives. +/// Cheap fingerprint of one retail archive root: a per-archive list of name, size and +/// timestamp from a single directory listing, never content hashes, so revalidation never +/// rereads gigabytes of archives. /// public class LaunchReceiptArchiveRoot { /// Gets or sets the archive root path. public string Path { get; set; } = string.Empty; - /// Gets or sets the number of archives in the root. + /// Gets or sets the number of archives in the root; a summary of . public int ArchiveCount { get; set; } - /// Gets or sets the total size of the archives in the root, in bytes. + /// Gets or sets the total size of the archives in the root, in bytes; a summary of . public long TotalArchiveBytes { get; set; } + + /// Gets or sets the fingerprint of each archive in the root. + public List Archives { get; set; } = []; } diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs index 966b2b1a9..a1ff9fe57 100644 --- a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs @@ -32,11 +32,17 @@ public class LaunchReceiptContext public string WorkingDirectory { get; set; } = string.Empty; /// - /// Gets or sets the environment passed to the child process; the retail archive root - /// variables are read from it. + /// Gets or sets the environment GenHub built for the child process — retail archive + /// roots plus profile-defined variables, never the inherited process environment. /// public IReadOnlyDictionary EnvironmentVariables { get; set; } = new Dictionary(); + /// + /// Gets or sets the resolved variant and entry-point identity, when a game client + /// manifest is part of the launch. + /// + public LaunchReceiptVariant? Variant { get; set; } + /// Gets or sets the manifest identifiers resolved for the launch. public IReadOnlyList ManifestIds { get; set; } = []; diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs new file mode 100644 index 000000000..43f1e7076 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs @@ -0,0 +1,30 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// The resolved variant and entry-point identity that determined what a launch started: +/// the same ManifestVariantResolver resolution workspace preparation applies to the +/// game client manifest, re-run against the same manifest and host runtime at receipt time. +/// +public class LaunchReceiptVariant +{ + /// Gets or sets the game client manifest the resolution ran against. + public string GameClientManifestId { get; set; } = string.Empty; + + /// Gets or sets the host runtime identifier the resolution ran on, for example osx-arm64. + public string RuntimeIdentifier { get; set; } = string.Empty; + + /// Gets or sets a value indicating whether the manifest declares variants at all. + public bool HasVariants { get; set; } + + /// + /// Gets or sets the runtime identifiers of the variant that matched; empty when the + /// matched variant is platform-neutral or the manifest declares no variants. + /// + public List VariantRuntimeIdentifiers { get; set; } = []; + + /// Gets or sets the resolved entry point, relative to the workspace, when resolution succeeded. + public string? EntryPointRelativePath { get; set; } + + /// Gets or sets the resolver's stated reason for the outcome. + public string? Resolution { get; set; } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index ca8ce2a29..abf3ba822 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -937,10 +937,20 @@ public async Task LaunchProfileAsync_WithValidProfile_RecordsLaunchReceipt() ExecutablePath = Path.Combine(workspacePath, "generalszh"), }; var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; - var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content", Version = "1.0" }; + var manifest = new ContentManifest + { + Id = "1.0.genhub.mod.test", + Name = "Test Content", + Version = "1.0", + ContentType = GenHub.Core.Models.Enums.ContentType.GameClient, + EntryPoint = "generalszh", + Files = [new ManifestFile { RelativePath = "generalszh", IsExecutable = true }], + }; _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _manifestPoolMock.Setup(x => x.GetContentDirectoryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(_retailRoot)); _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( It.Is>(ids => ids.SequenceEqual(TestContentIds)), It.IsAny())) @@ -965,7 +975,10 @@ public async Task LaunchProfileAsync_WithValidProfile_RecordsLaunchReceipt() c.ExecutablePath == workspaceInfo.ExecutablePath && c.WorkspacePath == workspaceInfo.WorkspacePath && c.GameType == GameType.Generals && - c.ManifestIds.Contains("1.0.genhub.mod.test")), + c.ManifestIds.Contains("1.0.genhub.mod.test") && + c.Variant != null && + c.Variant.GameClientManifestId == "1.0.genhub.mod.test" && + c.Variant.EntryPointRelativePath == "generalszh"), It.IsAny()), Times.Once); } @@ -1012,6 +1025,8 @@ public async Task LaunchProfileAsync_WithReceiptDrift_DoesNotBlockLaunch() // Assert Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Contains(driftReport.DriftedFields[0], result.Data.ReceiptDriftWarnings); } /// @@ -1067,6 +1082,8 @@ public async Task LaunchProfileAsync_WithPreviousReceipt_ComparesUpcomingConfigu c.GameClientId == "version-1" && c.ExecutablePath == workspaceInfo.ExecutablePath)), Times.Once); + Assert.NotNull(result.Data); + Assert.Contains("Game client changed from old-client to version-1", result.Data.ReceiptDriftWarnings); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs index eef53d6b5..d1187e000 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs @@ -116,11 +116,11 @@ public async Task RevalidateAsync_WithUnchangedState_ReportsNoDrift() } /// - /// An archive added to a root since the last launch is reported as count drift. + /// An archive added to a root since the last launch is reported by name. /// /// The async task. [Fact] - public async Task RevalidateAsync_WithChangedArchiveCount_ReportsDrift() + public async Task RevalidateAsync_WithAddedArchive_ReportsDrift() { await _service.RecordLaunchAsync(CreateContext()); File.WriteAllText(Path.Combine(_archiveRoot, "ModZH.big"), "a third archive"); @@ -130,11 +130,30 @@ public async Task RevalidateAsync_WithChangedArchiveCount_ReportsDrift() Assert.True(result.Success); Assert.True(result.Data!.HasDrift); Assert.Contains(result.Data.DriftedFields, f => - f.Contains("Archive count") && f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable)); + f.Contains("Archive added") && f.Contains("ModZH.big") && + f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable)); } /// - /// A mutated archive that keeps the count but changes total bytes is reported as drift. + /// A removed archive is reported by name. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithRemovedArchive_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + File.Delete(Path.Combine(_archiveRoot, "TexturesZH.big")); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => + f.Contains("Archive removed") && f.Contains("TexturesZH.big")); + } + + /// + /// A mutated archive with a different size is reported by name and both sizes. /// /// The async task. [Fact] @@ -148,7 +167,31 @@ public async Task RevalidateAsync_WithMutatedArchiveBytes_ReportsDrift() Assert.True(result.Success); Assert.True(result.Data!.HasDrift); Assert.Contains(result.Data.DriftedFields, f => - f.Contains("Total archive bytes") && f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable)); + f.Contains("INIZH.big") && f.Contains("changed size") && + f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable)); + } + + /// + /// An equal-size archive replacement — invisible to a count and byte total — is + /// reported through its changed timestamp. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithEqualSizeArchiveReplacement_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var archivePath = Path.Combine(_archiveRoot, "INIZH.big"); + var originalLength = new FileInfo(archivePath).Length; + File.WriteAllText(archivePath, "swapped bod"); + Assert.Equal(originalLength, new FileInfo(archivePath).Length); + File.SetLastWriteTimeUtc(archivePath, new FileInfo(archivePath).LastWriteTimeUtc.AddMinutes(1)); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasDrift); + Assert.Contains(result.Data.DriftedFields, f => + f.Contains("INIZH.big") && f.Contains("changed last-write time")); } /// @@ -351,6 +394,108 @@ public async Task CompareUpcomingLaunch_WithChangedManifestSet_ReportsDrift() f.Contains("added since the last launch") && f.Contains("1.0.genhub.mod.other")); } + /// + /// Recording captures the GenHub-built environment and the variant identity, and both + /// round-trip through the receipt. + /// + /// The async task. + [Fact] + public async Task RecordLaunchAsync_CapturesEnvironmentAndVariantIdentity() + { + await _service.RecordLaunchAsync(CreateContext()); + + var receipt = await ReadReceiptAsync(); + Assert.Equal("alpha", receipt.EnvironmentVariables["GENHUB_TEST_VARIABLE"]); + Assert.NotNull(receipt.Variant); + Assert.Equal("1.0.genhub.mod.test", receipt.Variant.GameClientManifestId); + Assert.Equal("generalszh", receipt.Variant.EntryPointRelativePath); + Assert.Equal(["osx-arm64"], receipt.Variant.VariantRuntimeIdentifiers); + + var recordedRoot = Assert.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable, receipt.ArchiveRoots); + Assert.Equal(2, recordedRoot.Archives.Count); + Assert.Contains(recordedRoot.Archives, a => a.FileName == "INIZH.big" && a.SizeBytes > 0); + } + + /// + /// A changed profile-defined environment variable is reported as drift naming the + /// variable and both values. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedEnvironmentVariable_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var report = _service.CompareUpcomingLaunch(receipt, CreateContext(environmentValue: "beta")); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => + f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("changed from alpha to beta")); + } + + /// + /// An environment variable that is no longer set, and one newly set, are each named. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedEnvironmentSet_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var upcoming = CreateContext(); + upcoming.EnvironmentVariables = new Dictionary + { + [RetailArchiveConstants.ZeroHourInstallPathVariable] = _archiveRoot + Path.DirectorySeparatorChar, + ["GENHUB_OTHER_VARIABLE"] = "1", + }; + + var report = _service.CompareUpcomingLaunch(receipt, upcoming); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => + f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("no longer set")); + Assert.Contains(report.DriftedFields, f => + f.Contains("GENHUB_OTHER_VARIABLE") && f.Contains("newly set")); + } + + /// + /// A changed resolved entry point is reported as variant drift. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithChangedEntryPoint_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var report = _service.CompareUpcomingLaunch(receipt, CreateContext(entryPoint: "otherclient")); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => + f.Contains("Entry point changed from generalszh to otherclient")); + } + + /// + /// A variant identity that stops being resolvable is reported, not ignored. + /// + /// The async task. + [Fact] + public async Task CompareUpcomingLaunch_WithVariantNoLongerResolvable_ReportsDrift() + { + await _service.RecordLaunchAsync(CreateContext()); + var receipt = await ReadReceiptAsync(); + + var upcoming = CreateContext(); + upcoming.Variant = null; + + var report = _service.CompareUpcomingLaunch(receipt, upcoming); + + Assert.True(report.HasDrift); + Assert.Contains(report.DriftedFields, f => f.Contains("Variant identity no longer resolvable")); + } + /// /// Recording into a missing workspace fails without throwing. /// @@ -407,6 +552,8 @@ private async Task ReadReceiptAsync() /// The executable path; the fixture executable when null. /// The archive root; the fixture root when null. /// The version of the single fixture manifest. + /// The value of the profile-defined environment variable. + /// The resolved entry point of the variant identity. /// The context. private LaunchReceiptContext CreateContext( string launchId = "launch-1", @@ -414,7 +561,9 @@ private LaunchReceiptContext CreateContext( GameType gameType = GameType.ZeroHour, string? executablePath = null, string? archiveRoot = null, - string manifestVersion = "1.0") + string manifestVersion = "1.0", + string environmentValue = "alpha", + string entryPoint = "generalszh") { return new LaunchReceiptContext { @@ -430,9 +579,19 @@ private LaunchReceiptContext CreateContext( { [RetailArchiveConstants.ZeroHourInstallPathVariable] = (archiveRoot ?? _archiveRoot) + Path.DirectorySeparatorChar, + ["GENHUB_TEST_VARIABLE"] = environmentValue, }, ManifestIds = ["1.0.genhub.mod.test"], ManifestVersions = new Dictionary { ["1.0.genhub.mod.test"] = manifestVersion }, + Variant = new LaunchReceiptVariant + { + GameClientManifestId = "1.0.genhub.mod.test", + RuntimeIdentifier = "osx-arm64", + HasVariants = true, + VariantRuntimeIdentifiers = ["osx-arm64"], + EntryPointRelativePath = entryPoint, + Resolution = "declared entry point", + }, }; } } diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 054f16336..5b8d81e83 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -743,6 +743,38 @@ private static LaunchReceiptContext BuildLaunchReceiptContext( EnvironmentVariables = launchConfig.EnvironmentVariables, ManifestIds = manifests.Select(m => m.Id.Value).ToList(), ManifestVersions = manifestVersions, + Variant = ResolveVariantIdentity(manifests), + }; + } + + /// + /// Resolves the variant and entry-point identity for the receipt by re-running the + /// same resolution workspace preparation applies + /// to the game client manifest — same manifest, same host runtime, same outcome. Null + /// when no game client manifest is part of the launch: that is the legacy fallback, + /// which resolves the executable by filename search with no variant machinery involved. + /// + /// The manifests resolved for the launch. + /// The identity, or null when nothing variant-shaped participated. + private static LaunchReceiptVariant? ResolveVariantIdentity(IReadOnlyList manifests) + { + var gameClientManifest = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient); + if (gameClientManifest is null) + { + return null; + } + + var variant = ManifestVariantResolver.ResolveVariant(gameClientManifest); + var entryPoint = ManifestVariantResolver.ResolveEntryPoint(gameClientManifest); + + return new LaunchReceiptVariant + { + GameClientManifestId = gameClientManifest.Id.Value, + RuntimeIdentifier = ManifestVariantResolver.CurrentRuntimeIdentifier, + HasVariants = gameClientManifest.Variants.Count > 0, + VariantRuntimeIdentifiers = variant is null ? [] : [.. variant.RuntimeIdentifiers], + EntryPointRelativePath = entryPoint.RelativePath, + Resolution = entryPoint.Reason, }; } @@ -969,9 +1001,11 @@ private async Task> LaunchProfileAsync(Gam // not in the manifests — the previous receipt does not survive it — and because // drift since the last launch is what matters, not the effects of this one. The // parsed receipt is carried forward so the upcoming launch's configuration can - // be compared against it once it is fully built. + // be compared against it once it is fully built, and every drift warning is + // collected so the launch result can surface it alongside the logs. + var receiptDriftWarnings = new List(); var previousReceipt = await RevalidateLaunchReceiptAsync( - Path.Combine(dynamicWorkspacePath, profile.Id), profile.Id, cancellationToken); + Path.Combine(dynamicWorkspacePath, profile.Id), profile.Id, receiptDriftWarnings, cancellationToken); logger.LogInformation("[GameLauncher] Preparing workspace at: {WorkspacePath}", workspaceConfig.WorkspaceRootPath); var workspaceProgress = new Progress( @@ -1263,7 +1297,9 @@ private async Task> LaunchProfileAsync(Gam var receiptContext = BuildLaunchReceiptContext(profile, gameClient, workspaceInfo, launchConfig, manifests, launchId); if (previousReceipt is not null) { - LogReceiptDrift(profile.Id, launchReceiptService.CompareUpcomingLaunch(previousReceipt, receiptContext)); + var configurationDrift = launchReceiptService.CompareUpcomingLaunch(previousReceipt, receiptContext); + LogReceiptDrift(profile.Id, configurationDrift); + receiptDriftWarnings.AddRange(configurationDrift.DriftedFields); } logger.LogInformation("[GameLauncher] Starting game process..."); @@ -1369,6 +1405,7 @@ private async Task> LaunchProfileAsync(Gam WorkspaceId = workspaceInfo.Id, ProcessInfo = processInfo, LaunchedAt = DateTime.UtcNow, + ReceiptDriftWarnings = receiptDriftWarnings, }; logger.LogDebug("[GameLauncher] Updating launch registry with real process info"); await launchRegistry.RegisterLaunchAsync(launchInfo); @@ -1405,9 +1442,10 @@ private async Task> LaunchProfileAsync(Gam /// /// The workspace directory the receipt would live in. /// The profile being launched. + /// Collects the drifted fields for the launch result. /// A cancellation token to observe while waiting for the task to complete. /// The parsed receipt, when one was present and readable, for the later configuration comparison. - private async Task RevalidateLaunchReceiptAsync(string workspacePath, string profileId, CancellationToken cancellationToken) + private async Task RevalidateLaunchReceiptAsync(string workspacePath, string profileId, List driftWarnings, CancellationToken cancellationToken) { var driftResult = await launchReceiptService.RevalidateAsync(workspacePath, cancellationToken); if (!driftResult.Success) @@ -1427,6 +1465,7 @@ private async Task> LaunchProfileAsync(Gam } LogReceiptDrift(profileId, driftReport); + driftWarnings.AddRange(driftReport.DriftedFields); return driftReport.Receipt; } diff --git a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs index beb8bb1bb..02de05ee3 100644 --- a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs +++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; @@ -53,6 +54,7 @@ public async Task> RecordLaunchAsync(LaunchReceip WorkingDirectory = context.WorkingDirectory, Executable = await FingerprintExecutableAsync(context.ExecutablePath, cancellationToken), ManifestIds = [.. context.ManifestIds], + Variant = context.Variant, }; foreach (var (manifestId, version) in context.ManifestVersions) @@ -60,6 +62,11 @@ public async Task> RecordLaunchAsync(LaunchReceip receipt.ManifestVersions[manifestId] = version; } + foreach (var (variableName, value) in context.EnvironmentVariables) + { + receipt.EnvironmentVariables[variableName] = value; + } + foreach (var variableName in RetailArchiveConstants.InstallPathVariables) { if (context.EnvironmentVariables.TryGetValue(variableName, out var root) && @@ -155,6 +162,8 @@ public LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, Lau CompareManifests(receipt, upcoming, report); CompareArchiveRootConfiguration(receipt, upcoming, report); + CompareEnvironment(receipt, upcoming, report); + CompareVariant(receipt.Variant, upcoming.Variant, report); return report; } @@ -262,7 +271,110 @@ private static void CompareArchiveRootConfiguration(LaunchReceipt receipt, Launc } /// - /// Fingerprints one archive root by archive count and total bytes. + /// Compares the GenHub-built child environment against the receipt, per variable. The + /// retail archive root variables are excluded here because + /// already names their changes. + /// + /// The receipt from the previous launch. + /// The configuration of the launch about to happen. + /// The report drifted fields are added to. + private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report) + { + foreach (var (variableName, recordedValue) in receipt.EnvironmentVariables) + { + if (IsArchiveRootVariable(variableName)) + { + continue; + } + + if (!upcoming.EnvironmentVariables.TryGetValue(variableName, out var upcomingValue)) + { + report.DriftedFields.Add($"Environment variable {variableName} no longer set; was {recordedValue}"); + } + else if (!string.Equals(recordedValue, upcomingValue, StringComparison.Ordinal)) + { + report.DriftedFields.Add( + $"Environment variable {variableName} changed from {recordedValue} to {upcomingValue}"); + } + } + + foreach (var (variableName, upcomingValue) in upcoming.EnvironmentVariables) + { + if (!IsArchiveRootVariable(variableName) && + !receipt.EnvironmentVariables.ContainsKey(variableName)) + { + report.DriftedFields.Add($"Environment variable {variableName} newly set: {upcomingValue}"); + } + } + } + + /// + /// Determines whether a variable is one of the retail archive root variables. + /// + /// The variable name. + /// Whether it carries an archive root. + private static bool IsArchiveRootVariable(string variableName) + { + foreach (var rootVariable in RetailArchiveConstants.InstallPathVariables) + { + if (string.Equals(variableName, rootVariable, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// Compares the resolved variant and entry-point identity against the receipt. + /// + /// The identity recorded at the previous launch, if any. + /// The identity resolved for the launch about to happen, if any. + /// The report drifted fields are added to. + private static void CompareVariant(LaunchReceiptVariant? recorded, LaunchReceiptVariant? upcoming, LaunchReceiptDriftReport report) + { + if (recorded is null && upcoming is null) + { + return; + } + + if (recorded is null) + { + report.DriftedFields.Add( + $"Variant identity newly resolvable; entry point is {upcoming!.EntryPointRelativePath ?? "(unresolved)"}"); + return; + } + + if (upcoming is null) + { + report.DriftedFields.Add( + $"Variant identity no longer resolvable; entry point was {recorded.EntryPointRelativePath ?? "(unresolved)"}"); + return; + } + + if (!string.Equals(recorded.RuntimeIdentifier, upcoming.RuntimeIdentifier, StringComparison.OrdinalIgnoreCase)) + { + report.DriftedFields.Add( + $"Host runtime identifier changed from {recorded.RuntimeIdentifier} to {upcoming.RuntimeIdentifier}"); + } + + if (!recorded.VariantRuntimeIdentifiers.SequenceEqual(upcoming.VariantRuntimeIdentifiers, StringComparer.OrdinalIgnoreCase)) + { + report.DriftedFields.Add( + $"Resolved variant changed from [{string.Join(", ", recorded.VariantRuntimeIdentifiers)}] to [{string.Join(", ", upcoming.VariantRuntimeIdentifiers)}]"); + } + + if (!string.Equals(recorded.EntryPointRelativePath, upcoming.EntryPointRelativePath, StringComparison.Ordinal)) + { + report.DriftedFields.Add( + $"Entry point changed from {recorded.EntryPointRelativePath ?? "(unresolved)"} to {upcoming.EntryPointRelativePath ?? "(unresolved)"}"); + } + } + + /// + /// Fingerprints one archive root as a per-archive list of name, size and timestamp + /// from a single directory listing, with the count and byte total derived from it. /// /// The archive root. /// The fingerprint; a missing root fingerprints as zero archives. @@ -277,8 +389,15 @@ private static LaunchReceiptArchiveRoot FingerprintArchiveRoot(string rootPath) foreach (var archivePath in Directory.EnumerateFiles( rootPath, RetailArchiveConstants.ArchiveSearchPattern, RetailArchiveConstants.ArchiveSearch)) { + var info = new FileInfo(archivePath); + fingerprint.Archives.Add(new LaunchReceiptArchiveEntry + { + FileName = info.Name, + SizeBytes = info.Length, + LastWriteUtc = info.LastWriteTimeUtc, + }); fingerprint.ArchiveCount++; - fingerprint.TotalArchiveBytes += new FileInfo(archivePath).Length; + fingerprint.TotalArchiveBytes += info.Length; } return fingerprint; @@ -319,7 +438,9 @@ private static void CompareExecutable(LaunchReceiptExecutable recorded, LaunchRe } /// - /// Compares one recorded archive-root fingerprint against the directory on disk. + /// Compares one recorded archive-root fingerprint against the directory on disk, + /// naming each added, removed or changed archive. Size and timestamp per archive make + /// an equal-size replacement visible, which a count and byte total alone cannot see. /// /// The environment variable that carried the root. /// The recorded fingerprint. @@ -343,19 +464,55 @@ private static void CompareArchiveRoot(string variableName, LaunchReceiptArchive return; } - if (current.ArchiveCount != recorded.ArchiveCount) + var recordedByName = IndexArchivesByName(recorded.Archives); + var currentByName = IndexArchivesByName(current.Archives); + + foreach (var (fileName, recordedArchive) in recordedByName) { - report.DriftedFields.Add( - $"Archive count for {variableName} changed from {recorded.ArchiveCount} to {current.ArchiveCount}: {recorded.Path}"); + if (!currentByName.TryGetValue(fileName, out var currentArchive)) + { + report.DriftedFields.Add($"Archive removed from root for {variableName}: {recordedArchive.FileName}"); + continue; + } + + if (currentArchive.SizeBytes != recordedArchive.SizeBytes) + { + report.DriftedFields.Add( + $"Archive {recordedArchive.FileName} in root for {variableName} changed size from {recordedArchive.SizeBytes} to {currentArchive.SizeBytes} bytes"); + } + else if (currentArchive.LastWriteUtc != recordedArchive.LastWriteUtc) + { + report.DriftedFields.Add( + $"Archive {recordedArchive.FileName} in root for {variableName} changed last-write time from {recordedArchive.LastWriteUtc:O} to {currentArchive.LastWriteUtc:O}"); + } } - if (current.TotalArchiveBytes != recorded.TotalArchiveBytes) + foreach (var (fileName, currentArchive) in currentByName) { - report.DriftedFields.Add( - $"Total archive bytes for {variableName} changed from {recorded.TotalArchiveBytes} to {current.TotalArchiveBytes}: {recorded.Path}"); + if (!recordedByName.ContainsKey(fileName)) + { + report.DriftedFields.Add($"Archive added to root for {variableName}: {currentArchive.FileName}"); + } } } + /// + /// Indexes archive fingerprints by file name, case-insensitively to match how the + /// archives themselves are enumerated. + /// + /// The fingerprints to index. + /// The index. + private static Dictionary IndexArchivesByName(IEnumerable archives) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var archive in archives) + { + index[archive.FileName] = archive; + } + + return index; + } + /// /// Fingerprints the executable, including the one hash recording pays for. /// From 970d237073878bd09d84090ee14c74f80509fdae Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Thu, 30 Jul 2026 18:51:57 +0100 Subject: [PATCH 4/7] feat(launching): surface receipt drift as an informational notice after launch --- .../GameProfileLauncherViewModelTests.cs | 118 ++++++++++++++++++ .../GameProfileLauncherViewModel.cs | 30 +++++ 2 files changed, 148 insertions(+) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs index fea3b7161..1ce3945b2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs @@ -14,6 +14,7 @@ using GenHub.Core.Models.GameClients; using GenHub.Core.Models.GameInstallations; using GenHub.Core.Models.GameProfile; +using GenHub.Core.Models.Launching; using GenHub.Core.Models.Results; using GenHub.Features.Content.Services.Publishers; using GenHub.Features.GameProfiles.Services; @@ -307,6 +308,80 @@ public void GenerateUniqueProfileName_CreatesUniqueName() Assert.Equal($"Test Profile {string.Format(ProfileConstants.CopyNameNumberedFormat, 3)}", uniqueName); } + /// + /// Verifies that a successful launch carrying receipt drift shows an informational + /// notice naming the drift, while the success presentation stays unchanged. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task LaunchProfileCommand_WithReceiptDrift_ShowsInformationalNotice() + { + var notificationService = new Mock(); + var launcherFacade = new Mock(); + var launchInfo = new GameLaunchInfo + { + LaunchId = "launch-1", + ProfileId = "profile-1", + WorkspaceId = "profile-1", + ProcessInfo = new GameProcessInfo { ProcessId = 123 }, + ReceiptDriftWarnings = ["Executable size changed from 1 to 2 bytes: generalszh"], + }; + launcherFacade.Setup(x => x.LaunchProfileAsync("profile-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(launchInfo)); + + var vm = CreateLauncherViewModel(launcherFacade, notificationService); + var profileItem = CreateProfileItem("profile-1", "Test Profile"); + + await vm.LaunchProfileCommand.ExecuteAsync(profileItem); + + Assert.Contains("launched successfully", vm.StatusMessage); + notificationService.Verify( + x => x.ShowSuccess("Game Launched", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + notificationService.Verify( + x => x.ShowInfo( + "Launch Configuration Changed", + It.Is(m => + m.Contains("Launch configuration changed since the last run") && + m.Contains("Executable size changed from 1 to 2 bytes")), + It.IsAny(), + It.IsAny()), + Times.Once); + notificationService.Verify( + x => x.ShowError(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that a successful launch without receipt drift shows no notice. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task LaunchProfileCommand_WithoutReceiptDrift_ShowsNoNotice() + { + var notificationService = new Mock(); + var launcherFacade = new Mock(); + var launchInfo = new GameLaunchInfo + { + LaunchId = "launch-1", + ProfileId = "profile-1", + WorkspaceId = "profile-1", + ProcessInfo = new GameProcessInfo { ProcessId = 123 }, + }; + launcherFacade.Setup(x => x.LaunchProfileAsync("profile-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(launchInfo)); + + var vm = CreateLauncherViewModel(launcherFacade, notificationService); + var profileItem = CreateProfileItem("profile-1", "Test Profile"); + + await vm.LaunchProfileCommand.ExecuteAsync(profileItem); + + Assert.Contains("launched successfully", vm.StatusMessage); + notificationService.Verify( + x => x.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + private static ProfileResourceService CreateProfileResourceService() { return new ProfileResourceService(NullLogger.Instance); @@ -336,6 +411,49 @@ private static SuperHackersProvider CreateSuperHackersProvider() NullLogger.Instance); } + /// + /// Creates a profile item bound to a mocked profile. + /// + /// The profile identifier. + /// The profile name. + /// The profile item. + private static GameProfileItemViewModel CreateProfileItem(string profileId, string name) + { + var profile = new Mock(); + profile.Setup(x => x.Name).Returns(name); + return new GameProfileItemViewModel(profileId, profile.Object, "icon.png", "cover.jpg"); + } + + /// + /// Creates a GameProfileLauncherViewModel wired to the given launcher facade and + /// notification service, with everything else mocked. + /// + /// The launcher facade mock. + /// The notification service mock. + /// The view model. + private static GameProfileLauncherViewModel CreateLauncherViewModel( + Mock launcherFacade, + Mock notificationService) + { + return new GameProfileLauncherViewModel( + new Mock().Object, + new Mock().Object, + launcherFacade.Object, + null!, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + CreateProfileResourceService(), + new Mock().Object, + notificationService.Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); + } + /// /// Creates a GameProfileLauncherViewModel with mocked dependencies for testing. /// diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs index 838cb2241..a1cd2618e 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs @@ -60,6 +60,8 @@ public partial class GameProfileLauncherViewModel( IRecipient, IRecipient { + private const int MaxReceiptDriftNoticeLines = 5; + private readonly SemaphoreSlim _launchSemaphore = new(1, 1); private readonly System.Timers.Timer _headerCollapseTimer = new(TimeIntervals.HeaderCollapseDelayMs); private readonly System.Timers.Timer _headerExpansionTimer = new(TimeIntervals.HeaderExpansionDelayMs); @@ -987,6 +989,25 @@ private async Task LaunchProfileAsync(GameProfileItemViewModel profile) } } + /// + /// Composes the informational receipt-drift notice: a lead line plus the drifted + /// fields, capped so a long list does not flood the notification. Full detail stays + /// in the logs. + /// + /// The drifted fields from the launch result. + /// The notice text. + private string BuildReceiptDriftNotice(IReadOnlyList driftWarnings) + { + var lines = new List { "Launch configuration changed since the last run:" }; + lines.AddRange(driftWarnings.Take(MaxReceiptDriftNoticeLines)); + if (driftWarnings.Count > MaxReceiptDriftNoticeLines) + { + lines.Add($"...and {driftWarnings.Count - MaxReceiptDriftNoticeLines} more; see the logs for full detail."); + } + + return string.Join(Environment.NewLine, lines); + } + /// /// Executes the actual launch operation. /// @@ -1010,6 +1031,15 @@ private async Task ExecuteLaunchAsync(GameProfileItemViewModel profile) StatusMessage = $"{liveProfile.Name} launched successfully (Process ID: {launchResult.Data.ProcessInfo.ProcessId})"; notificationService.ShowSuccess("Game Launched", $"{liveProfile.Name} is now running."); + + // Advisory by design: receipt drift never blocks or fails a launch, so it is + // surfaced as information beside the success, never through the error channel. + if (launchResult.Data.ReceiptDriftWarnings.Count > 0) + { + notificationService.ShowInfo( + "Launch Configuration Changed", + BuildReceiptDriftNotice(launchResult.Data.ReceiptDriftWarnings)); + } } else { From ba47c9d9226a20a0aeba9a2682a9e3a39a4edb99 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 10 Aug 2026 12:47:14 -0400 Subject: [PATCH 5/7] fix(launching): keep environment values out of launch receipts and drift messages --- .../Models/Launching/LaunchReceipt.cs | 13 ++- .../Launching/LaunchReceiptServiceTests.cs | 48 ++++++++++- .../Launching/LaunchReceiptService.cs | 84 +++++++++++++++---- 3 files changed, 123 insertions(+), 22 deletions(-) diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs index 2b78742f9..84ee7e4c4 100644 --- a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs @@ -42,13 +42,20 @@ public class LaunchReceipt public Dictionary ArchiveRoots { get; set; } = []; /// - /// Gets or sets the environment variables GenHub itself set for the child process: the - /// built launch environment — retail archive roots plus any profile-defined variables. + /// Gets or sets a hash per environment variable GenHub itself set for the child process: + /// the built launch environment — retail archive roots plus any profile-defined variables. /// The inherited process environment is deliberately not recorded; it is large, differs /// between hosts without meaning anything for the launch, and can carry secrets that a /// receipt on disk must never capture. /// - public Dictionary EnvironmentVariables { get; set; } = []; + /// + /// Values are hashed rather than stored, because a profile-defined variable can itself + /// carry a secret and detecting drift only needs to know that a value changed, not what + /// it changed to. Archive root paths are exempt and recorded in full under + /// : they are locations, not credentials, and naming them is + /// what makes a misconfigured root actionable. + /// + public Dictionary EnvironmentVariableHashes { get; set; } = []; /// /// Gets or sets the resolved variant and entry-point identity that determined what was diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs index d1187e000..bc30418f8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs @@ -262,6 +262,25 @@ public async Task RevalidateAsync_WithCorruptReceipt_ReportsDrift() Assert.Contains(result.Data.DriftedFields, f => f.Contains("could not be read")); } + /// + /// A receipt that parses but carries null fields is reported as drift, not thrown. + /// Revalidation is awaited on the launch path, so an escaping exception would fail the + /// launch — which drift is never allowed to do. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithNullReceiptFields_ReportsDriftWithoutThrowing() + { + await File.WriteAllTextAsync( + Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName), + """{"SchemaVersion":1,"Executable":null,"ArchiveRoots":null,"EnvironmentVariableHashes":null}"""); + + var result = await _service.RevalidateAsync(_workspacePath); + + Assert.True(result.Success); + Assert.True(result.Data!.HasReceipt); + } + /// /// An upcoming launch identical to the recorded one reports no configuration drift, and /// revalidation hands back the parsed receipt for that comparison. @@ -405,7 +424,7 @@ public async Task RecordLaunchAsync_CapturesEnvironmentAndVariantIdentity() await _service.RecordLaunchAsync(CreateContext()); var receipt = await ReadReceiptAsync(); - Assert.Equal("alpha", receipt.EnvironmentVariables["GENHUB_TEST_VARIABLE"]); + Assert.Contains("GENHUB_TEST_VARIABLE", receipt.EnvironmentVariableHashes.Keys); Assert.NotNull(receipt.Variant); Assert.Equal("1.0.genhub.mod.test", receipt.Variant.GameClientManifestId); Assert.Equal("generalszh", receipt.Variant.EntryPointRelativePath); @@ -418,7 +437,7 @@ public async Task RecordLaunchAsync_CapturesEnvironmentAndVariantIdentity() /// /// A changed profile-defined environment variable is reported as drift naming the - /// variable and both values. + /// variable, without either value appearing in the message. /// /// The async task. [Fact] @@ -431,7 +450,30 @@ public async Task CompareUpcomingLaunch_WithChangedEnvironmentVariable_ReportsDr Assert.True(report.HasDrift); Assert.Contains(report.DriftedFields, f => - f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("changed from alpha to beta")); + f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("changed value")); + Assert.DoesNotContain(report.DriftedFields, f => f.Contains("alpha") || f.Contains("beta")); + } + + /// + /// A profile-defined environment value never reaches the receipt on disk, nor the drift + /// messages that carry it into the log and the post-launch notice. + /// + /// The async task. + [Fact] + public async Task RecordLaunchAsync_DoesNotPersistEnvironmentValues() + { + const string secret = "s3cr3t-token-value"; + + await _service.RecordLaunchAsync(CreateContext(environmentValue: secret)); + + var receiptJson = await File.ReadAllTextAsync( + Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName)); + Assert.DoesNotContain(secret, receiptJson, StringComparison.Ordinal); + Assert.Contains("GENHUB_TEST_VARIABLE", receiptJson, StringComparison.Ordinal); + + var receipt = await ReadReceiptAsync(); + var report = _service.CompareUpcomingLaunch(receipt, CreateContext(environmentValue: "replacement")); + Assert.DoesNotContain(report.DriftedFields, f => f.Contains(secret)); } /// diff --git a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs index 02de05ee3..127edcc41 100644 --- a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs +++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs @@ -2,11 +2,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Launching; using GenHub.Core.Models.Launching; @@ -64,7 +67,7 @@ public async Task> RecordLaunchAsync(LaunchReceip foreach (var (variableName, value) in context.EnvironmentVariables) { - receipt.EnvironmentVariables[variableName] = value; + receipt.EnvironmentVariableHashes[variableName] = HashEnvironmentValue(value); } foreach (var variableName in RetailArchiveConstants.InstallPathVariables) @@ -125,10 +128,34 @@ public async Task> RevalidateAsync(str } report.Receipt = receipt; - CompareExecutable(receipt.Executable, report); - foreach (var (variableName, recordedRoot) in receipt.ArchiveRoots) + + // Guarded as widely as the parse above. A receipt that parses but carries null or + // malformed fields must degrade to a drift line, never to an exception: revalidation + // is awaited on the launch path, so anything escaping here fails the launch through + // LaunchProfileAsync's catch-all — the opposite of the guarantee this method makes. + try { - CompareArchiveRoot(variableName, recordedRoot, report); + if (receipt.Executable is not null) + { + CompareExecutable(receipt.Executable, report); + } + + var recordedRoots = receipt.ArchiveRoots; + if (recordedRoots is not null) + { + foreach (var (variableName, recordedRoot) in recordedRoots) + { + if (recordedRoot is not null) + { + CompareArchiveRoot(variableName, recordedRoot, report); + } + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Launch receipt at {ReceiptPath} could not be revalidated", receiptPath); + report.DriftedFields.Add($"Receipt could not be revalidated: {receiptPath} ({ex.Message})"); } return OperationResult.CreateSuccess(report); @@ -180,14 +207,28 @@ private static string GetReceiptPath(string workspacePath) => /// Compares two paths ignoring a trailing directory separator, which the archive root /// variables carry by engine requirement and other paths do not. /// + /// + /// Separators are normalised and the comparison follows platform casing rules, so the + /// same location written two ways is not reported as drift. Compared as text rather than + /// resolved through the filesystem: this runs before the launch and a recorded path that + /// no longer exists is drift to report, not an exception to throw. + /// /// One path. /// The other path. /// Whether the paths are equal. private static bool PathsEqual(string left, string right) => - string.Equals( - Path.TrimEndingDirectorySeparator(left), - Path.TrimEndingDirectorySeparator(right), - StringComparison.Ordinal); + string.Equals(NormalizePath(left), NormalizePath(right), PathHelper.PathComparison); + + /// + /// Normalises a path for comparison by unifying separators and dropping a trailing one. + /// + /// The path to normalise. + /// The normalised path. + private static string NormalizePath(string path) => + string.IsNullOrEmpty(path) + ? string.Empty + : Path.TrimEndingDirectorySeparator( + path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)); /// /// Compares the recorded manifest set and versions against the upcoming launch's. @@ -280,7 +321,10 @@ private static void CompareArchiveRootConfiguration(LaunchReceipt receipt, Launc /// The report drifted fields are added to. private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report) { - foreach (var (variableName, recordedValue) in receipt.EnvironmentVariables) + // Names, never values. These lines reach the log and the post-launch notice, both of + // which travel further than the machine that produced them, and a profile-defined + // variable can carry a credential. Which variable changed is the actionable part. + foreach (var (variableName, recordedHash) in receipt.EnvironmentVariableHashes) { if (IsArchiveRootVariable(variableName)) { @@ -289,25 +333,33 @@ private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptConte if (!upcoming.EnvironmentVariables.TryGetValue(variableName, out var upcomingValue)) { - report.DriftedFields.Add($"Environment variable {variableName} no longer set; was {recordedValue}"); + report.DriftedFields.Add($"Environment variable {variableName} is no longer set"); } - else if (!string.Equals(recordedValue, upcomingValue, StringComparison.Ordinal)) + else if (!string.Equals(recordedHash, HashEnvironmentValue(upcomingValue), StringComparison.Ordinal)) { - report.DriftedFields.Add( - $"Environment variable {variableName} changed from {recordedValue} to {upcomingValue}"); + report.DriftedFields.Add($"Environment variable {variableName} changed value"); } } - foreach (var (variableName, upcomingValue) in upcoming.EnvironmentVariables) + foreach (var (variableName, _) in upcoming.EnvironmentVariables) { if (!IsArchiveRootVariable(variableName) && - !receipt.EnvironmentVariables.ContainsKey(variableName)) + !receipt.EnvironmentVariableHashes.ContainsKey(variableName)) { - report.DriftedFields.Add($"Environment variable {variableName} newly set: {upcomingValue}"); + report.DriftedFields.Add($"Environment variable {variableName} is newly set"); } } } + /// + /// Hashes an environment variable value so drift can be detected without the receipt, the + /// log or the post-launch notice ever carrying the value itself. + /// + /// The value to hash. + /// The lowercase hexadecimal SHA-256 of the value. + private static string HashEnvironmentValue(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty))).ToLowerInvariant(); + /// /// Determines whether a variable is one of the retail archive root variables. /// From 3b7c43052b49357f1d6cdce0224256e074c2284f Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 10 Aug 2026 16:05:44 -0400 Subject: [PATCH 6/7] fix(launching): tolerate null receipt fields on the comparison path --- .../Launching/LaunchReceiptServiceTests.cs | 5 ++ .../Launching/LaunchReceiptService.cs | 61 ++++++++++++------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs index bc30418f8..446d6c87f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs @@ -279,6 +279,11 @@ await File.WriteAllTextAsync( Assert.True(result.Success); Assert.True(result.Data!.HasReceipt); + + // The comparison path runs on the same launch and reads the same null collections, + // so tolerating them on read alone would still fail the launch a step later. + var report = _service.CompareUpcomingLaunch(result.Data.Receipt!, CreateContext()); + Assert.NotNull(report); } /// diff --git a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs index 127edcc41..c8704e52f 100644 --- a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs +++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs @@ -169,29 +169,42 @@ public LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, Lau var report = new LaunchReceiptDriftReport { HasReceipt = true, Receipt = receipt }; - if (!string.Equals(receipt.GameClientId ?? string.Empty, upcoming.GameClientId ?? string.Empty, StringComparison.Ordinal)) + // Guarded for the same reason RevalidateAsync is: this runs on the launch path, and a + // receipt that parses but carries null collections would otherwise throw here and fail + // the launch through LaunchProfileAsync's catch-all. Every field below comes from a + // deserialized file, so none of them can be assumed present. + try { - report.DriftedFields.Add( - $"Game client changed from {receipt.GameClientId ?? "(none)"} to {upcoming.GameClientId ?? "(none)"}"); - } + if (!string.Equals(receipt.GameClientId ?? string.Empty, upcoming.GameClientId ?? string.Empty, StringComparison.Ordinal)) + { + report.DriftedFields.Add( + $"Game client changed from {receipt.GameClientId ?? "(none)"} to {upcoming.GameClientId ?? "(none)"}"); + } - if (receipt.GameType != upcoming.GameType) - { - report.DriftedFields.Add($"Game type changed from {receipt.GameType} to {upcoming.GameType}"); - } + if (receipt.GameType != upcoming.GameType) + { + report.DriftedFields.Add($"Game type changed from {receipt.GameType} to {upcoming.GameType}"); + } + + if (receipt.Executable is not null && + !string.IsNullOrEmpty(upcoming.ExecutablePath) && + !PathsEqual(receipt.Executable.Path, upcoming.ExecutablePath)) + { + report.DriftedFields.Add( + $"Executable path changed from {receipt.Executable.Path} to {upcoming.ExecutablePath}"); + } - if (!string.IsNullOrEmpty(upcoming.ExecutablePath) && - !PathsEqual(receipt.Executable.Path, upcoming.ExecutablePath)) + CompareManifests(receipt, upcoming, report); + CompareArchiveRootConfiguration(receipt, upcoming, report); + CompareEnvironment(receipt, upcoming, report); + CompareVariant(receipt.Variant, upcoming.Variant, report); + } + catch (Exception ex) { - report.DriftedFields.Add( - $"Executable path changed from {receipt.Executable.Path} to {upcoming.ExecutablePath}"); + logger.LogWarning(ex, "Launch receipt for profile {ProfileId} could not be compared", upcoming.ProfileId); + report.DriftedFields.Add($"Receipt could not be compared against the upcoming launch ({ex.Message})"); } - CompareManifests(receipt, upcoming, report); - CompareArchiveRootConfiguration(receipt, upcoming, report); - CompareEnvironment(receipt, upcoming, report); - CompareVariant(receipt.Variant, upcoming.Variant, report); - return report; } @@ -238,10 +251,11 @@ private static string NormalizePath(string path) => /// The report drifted fields are added to. private static void CompareManifests(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report) { - var recordedIds = new HashSet(receipt.ManifestIds, StringComparer.Ordinal); + var recordedManifestIds = receipt.ManifestIds ?? []; + var recordedIds = new HashSet(recordedManifestIds, StringComparer.Ordinal); var upcomingIds = new HashSet(upcoming.ManifestIds, StringComparer.Ordinal); - foreach (var manifestId in receipt.ManifestIds) + foreach (var manifestId in recordedManifestIds) { if (!upcomingIds.Contains(manifestId)) { @@ -257,7 +271,7 @@ private static void CompareManifests(LaunchReceipt receipt, LaunchReceiptContext } } - foreach (var (manifestId, recordedVersion) in receipt.ManifestVersions) + foreach (var (manifestId, recordedVersion) in receipt.ManifestVersions ?? []) { if (upcoming.ManifestVersions.TryGetValue(manifestId, out var upcomingVersion) && !string.Equals(recordedVersion, upcomingVersion, StringComparison.Ordinal)) @@ -279,7 +293,7 @@ private static void CompareArchiveRootConfiguration(LaunchReceipt receipt, Launc { foreach (var variableName in RetailArchiveConstants.InstallPathVariables) { - receipt.ArchiveRoots.TryGetValue(variableName, out var recordedRoot); + (receipt.ArchiveRoots ?? []).TryGetValue(variableName, out var recordedRoot); var upcomingRoot = upcoming.EnvironmentVariables.TryGetValue(variableName, out var configured) && !string.IsNullOrWhiteSpace(configured) @@ -324,7 +338,8 @@ private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptConte // Names, never values. These lines reach the log and the post-launch notice, both of // which travel further than the machine that produced them, and a profile-defined // variable can carry a credential. Which variable changed is the actionable part. - foreach (var (variableName, recordedHash) in receipt.EnvironmentVariableHashes) + var recordedHashes = receipt.EnvironmentVariableHashes ?? []; + foreach (var (variableName, recordedHash) in recordedHashes) { if (IsArchiveRootVariable(variableName)) { @@ -344,7 +359,7 @@ private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptConte foreach (var (variableName, _) in upcoming.EnvironmentVariables) { if (!IsArchiveRootVariable(variableName) && - !receipt.EnvironmentVariableHashes.ContainsKey(variableName)) + !recordedHashes.ContainsKey(variableName)) { report.DriftedFields.Add($"Environment variable {variableName} is newly set"); } From bad323d33281e2b8f9854ecbe07b7957eced6f1c Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 10 Aug 2026 16:16:23 -0400 Subject: [PATCH 7/7] fix(launching): salt receipt environment hashes and isolate post-spawn recording --- .../Models/Launching/LaunchReceipt.cs | 13 +++++ .../Features/Launching/GameLauncherTests.cs | 52 +++++++++++++++++++ .../Launching/LaunchReceiptServiceTests.cs | 27 ++++++++++ .../GenHub/Features/Launching/GameLauncher.cs | 25 ++++++--- .../Launching/LaunchReceiptService.cs | 35 +++++++++---- 5 files changed, 137 insertions(+), 15 deletions(-) diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs index 84ee7e4c4..fb70411e4 100644 --- a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs @@ -57,6 +57,19 @@ public class LaunchReceipt /// public Dictionary EnvironmentVariableHashes { get; set; } = []; + /// + /// Gets or sets the random salt the environment value hashes were computed with, so the + /// same value hashes differently in every receipt. + /// + /// + /// Comparison always runs against the receipt that carries the salt, so drift detection is + /// unaffected. This does not defeat an attacker who holds the receipt and guesses likely + /// values — they hold the salt too — but it does stop precomputed tables, and it stops + /// receipts being compared across hosts or profiles to confirm that two installations share + /// a value without ever recovering it. + /// + public string EnvironmentHashSalt { get; set; } = string.Empty; + /// /// Gets or sets the resolved variant and entry-point identity that determined what was /// launched. Null when the profile carried no game client manifest: the legacy fallback diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index abf3ba822..3ae4c60cc 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -1125,6 +1125,58 @@ public async Task LaunchProfileAsync_WhenReceiptRecordingFails_StillSucceeds() Assert.True(result.Success); } + /// + /// Verifies a launch that has already started is not failed by a receipt-recording + /// exception, including cancellation: the child process is running by then, so reporting + /// failure would leave the caller believing a running game never started. + /// + /// The exception the recorder throws. + /// The async task. + [Theory] + [MemberData(nameof(ReceiptRecordingExceptions))] + public async Task LaunchProfileAsync_WhenReceiptRecordingThrows_StillSucceeds(Exception thrown) + { + // Arrange + var profile = CreateTestProfile(); + var workspacePath = Path.Combine(_retailRoot, "workspace"); + var workspaceInfo = new WorkspaceInfo + { + Id = profile.Id, + WorkspacePath = workspacePath, + ExecutablePath = Path.Combine(workspacePath, "generalszh"), + }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + _launchReceiptServiceMock.Setup(x => x.RecordLaunchAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(thrown); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success); + } + + /// + /// Gets the exceptions a post-spawn receipt write can realistically throw. + /// + public static TheoryData ReceiptRecordingExceptions => new() + { + new IOException("disk full"), + new OperationCanceledException(), + }; + /// /// Removes the temporary retail root. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs index 446d6c87f..e8b773aa2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs @@ -440,6 +440,33 @@ public async Task RecordLaunchAsync_CapturesEnvironmentAndVariantIdentity() Assert.Contains(recordedRoot.Archives, a => a.FileName == "INIZH.big" && a.SizeBytes > 0); } + /// + /// The same environment value hashes differently in two receipts, so receipts cannot be + /// compared across hosts or profiles to confirm a shared value. Drift still resolves, + /// because comparison runs against the receipt that carries the salt. + /// + /// The async task. + [Fact] + public async Task RecordLaunchAsync_SaltsEnvironmentHashesPerReceipt() + { + await _service.RecordLaunchAsync(CreateContext()); + var first = await ReadReceiptAsync(); + + await _service.RecordLaunchAsync(CreateContext()); + var second = await ReadReceiptAsync(); + + Assert.NotEqual(first.EnvironmentHashSalt, second.EnvironmentHashSalt); + Assert.NotEmpty(first.EnvironmentHashSalt); + Assert.NotEqual( + first.EnvironmentVariableHashes["GENHUB_TEST_VARIABLE"], + second.EnvironmentVariableHashes["GENHUB_TEST_VARIABLE"]); + + Assert.False(_service.CompareUpcomingLaunch(first, CreateContext()).HasDrift); + Assert.Contains( + _service.CompareUpcomingLaunch(first, CreateContext(environmentValue: "beta")).DriftedFields, + f => f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("changed value")); + } + /// /// A changed profile-defined environment variable is reported as drift naming the /// variable, without either value appearing in the message. diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 5b8d81e83..8d2755b97 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -1394,7 +1394,7 @@ private async Task> LaunchProfileAsync(Gam var processInfo = processResult.Data; logger.LogInformation("[GameLauncher] Process started successfully - PID: {ProcessId}", processInfo.ProcessId); - await RecordLaunchReceiptAsync(receiptContext, cancellationToken); + await RecordLaunchReceiptAsync(receiptContext); // Update the placeholder launch entry with real process info // (The placeholder was registered earlier to prevent deletion during launch) @@ -1489,15 +1489,28 @@ private void LogReceiptDrift(string profileId, LaunchReceiptDriftReport driftRep /// Records a receipt of what this launch consisted of into the workspace. A failure to /// record is logged and never fails a launch that has already started. /// + /// + /// Deliberately not passed the launch cancellation token: the child process is already + /// running by the time this is called, so cancelling the launch operation must not abandon + /// the write half-done, and the resulting must not + /// reach the caller's catch and report a running game as a failed launch. Everything else + /// is caught here for the same reason. + /// /// What the launch consisted of. - /// A cancellation token to observe while waiting for the task to complete. /// A task representing the asynchronous operation. - private async Task RecordLaunchReceiptAsync(LaunchReceiptContext receiptContext, CancellationToken cancellationToken) + private async Task RecordLaunchReceiptAsync(LaunchReceiptContext receiptContext) { - var receiptResult = await launchReceiptService.RecordLaunchAsync(receiptContext, cancellationToken); - if (!receiptResult.Success) + try + { + var receiptResult = await launchReceiptService.RecordLaunchAsync(receiptContext, CancellationToken.None); + if (!receiptResult.Success) + { + logger.LogWarning("[GameLauncher] Failed to record launch receipt: {Error}", receiptResult.FirstError); + } + } + catch (Exception ex) { - logger.LogWarning("[GameLauncher] Failed to record launch receipt: {Error}", receiptResult.FirstError); + logger.LogWarning(ex, "[GameLauncher] Failed to record launch receipt for profile {ProfileId}", receiptContext.ProfileId); } } diff --git a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs index c8704e52f..a3d34458c 100644 --- a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs +++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs @@ -65,9 +65,11 @@ public async Task> RecordLaunchAsync(LaunchReceip receipt.ManifestVersions[manifestId] = version; } + receipt.EnvironmentHashSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); foreach (var (variableName, value) in context.EnvironmentVariables) { - receipt.EnvironmentVariableHashes[variableName] = HashEnvironmentValue(value); + receipt.EnvironmentVariableHashes[variableName] = + HashEnvironmentValue(value, receipt.EnvironmentHashSalt); } foreach (var variableName in RetailArchiveConstants.InstallPathVariables) @@ -186,12 +188,11 @@ public LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, Lau report.DriftedFields.Add($"Game type changed from {receipt.GameType} to {upcoming.GameType}"); } - if (receipt.Executable is not null && - !string.IsNullOrEmpty(upcoming.ExecutablePath) && - !PathsEqual(receipt.Executable.Path, upcoming.ExecutablePath)) + if (!string.IsNullOrEmpty(upcoming.ExecutablePath) && + !PathsEqual(receipt.Executable?.Path ?? string.Empty, upcoming.ExecutablePath)) { report.DriftedFields.Add( - $"Executable path changed from {receipt.Executable.Path} to {upcoming.ExecutablePath}"); + $"Executable path changed from {NameOrNone(receipt.Executable?.Path)} to {upcoming.ExecutablePath}"); } CompareManifests(receipt, upcoming, report); @@ -232,6 +233,14 @@ private static string GetReceiptPath(string workspacePath) => private static bool PathsEqual(string left, string right) => string.Equals(NormalizePath(left), NormalizePath(right), PathHelper.PathComparison); + /// + /// Renders a recorded value that a corrupt receipt may have left unset. + /// + /// The value to render. + /// The value, or a placeholder when it is missing. + private static string NameOrNone(string? value) => + string.IsNullOrEmpty(value) ? "(none)" : value; + /// /// Normalises a path for comparison by unifying separators and dropping a trailing one. /// @@ -350,7 +359,10 @@ private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptConte { report.DriftedFields.Add($"Environment variable {variableName} is no longer set"); } - else if (!string.Equals(recordedHash, HashEnvironmentValue(upcomingValue), StringComparison.Ordinal)) + else if (!string.Equals( + recordedHash, + HashEnvironmentValue(upcomingValue, receipt.EnvironmentHashSalt), + StringComparison.Ordinal)) { report.DriftedFields.Add($"Environment variable {variableName} changed value"); } @@ -371,9 +383,14 @@ private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptConte /// log or the post-launch notice ever carrying the value itself. /// /// The value to hash. - /// The lowercase hexadecimal SHA-256 of the value. - private static string HashEnvironmentValue(string value) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty))).ToLowerInvariant(); + /// The receipt's salt, keying the hash so it is unique to that receipt. + /// The lowercase hexadecimal keyed hash of the value. + private static string HashEnvironmentValue(string value, string salt) => + Convert.ToHexString( + HMACSHA256.HashData( + Encoding.UTF8.GetBytes(salt ?? string.Empty), + Encoding.UTF8.GetBytes(value ?? string.Empty))) + .ToLowerInvariant(); /// /// Determines whether a variable is one of the retail archive root variables.