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..bba9909ea --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs @@ -0,0 +1,48 @@ +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); + + /// + /// 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/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 new file mode 100644 index 000000000..fb70411e4 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs @@ -0,0 +1,87 @@ +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 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. + /// + /// + /// 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 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 + /// 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; } = []; + + /// 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/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 new file mode 100644 index 000000000..b8fcb64cd --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs @@ -0,0 +1,21 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// 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; a summary of . + public int ArchiveCount { get; set; } + + /// 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 new file mode 100644 index 000000000..a1ff9fe57 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs @@ -0,0 +1,51 @@ +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 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; } = []; + + /// 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..1d53def11 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs @@ -0,0 +1,29 @@ +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 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; } = []; + + /// 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.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/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.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index 653652a44..3ae4c60cc 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,14 @@ 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())); + _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( It.IsAny>(), @@ -141,7 +150,8 @@ public GameLauncherTests() _gameSettingsServiceMock.Object, _profileContentLinkerMock.Object, _steamLauncherMock.Object, - _configurationProviderServiceMock.Object); + _configurationProviderServiceMock.Object, + _launchReceiptServiceMock.Object); } /// @@ -910,6 +920,263 @@ 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", + 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())) + .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") && + c.Variant != null && + c.Variant.GameClientManifestId == "1.0.genhub.mod.test" && + c.Variant.EntryPointRelativePath == "generalszh"), + 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); + Assert.NotNull(result.Data); + Assert.Contains(driftReport.DriftedFields[0], result.Data.ReceiptDriftWarnings); + } + + /// + /// 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); + Assert.NotNull(result.Data); + Assert.Contains("Game client changed from old-client to version-1", result.Data.ReceiptDriftWarnings); + } + + /// + /// 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); + } + + /// + /// 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 new file mode 100644 index 000000000..e8b773aa2 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs @@ -0,0 +1,671 @@ +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 by name. + /// + /// The async task. + [Fact] + public async Task RevalidateAsync_WithAddedArchive_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 added") && f.Contains("ModZH.big") && + f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable)); + } + + /// + /// 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] + 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("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")); + } + + /// + /// 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")); + } + + /// + /// 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); + + // 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); + } + + /// + /// 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 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.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); + 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); + } + + /// + /// 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. + /// + /// 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 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)); + } + + /// + /// 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. + /// + /// 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 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 value of the profile-defined environment variable. + /// The resolved entry point of the variant identity. + /// The context. + 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", + string environmentValue = "alpha", + string entryPoint = "generalszh") + { + return new LaunchReceiptContext + { + LaunchId = launchId, + ProfileId = "profile-1", + GameClientId = gameClientId, + GameType = gameType, + WorkspaceId = "profile-1", + WorkspacePath = _workspacePath, + ExecutablePath = executablePath ?? _executablePath, + WorkingDirectory = _workspacePath, + EnvironmentVariables = new Dictionary + { + [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/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 { diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 10e2c0fe7..8d2755b97 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 = @@ -703,6 +705,79 @@ 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, + 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, + }; + } + private async Task> LaunchProfileAsync(GameProfile profile, bool skipUserDataCleanup, IProgress? progress, string launchId, CancellationToken cancellationToken) { IDisposable? steamInstallationLock = null; @@ -922,6 +997,16 @@ 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. The + // parsed receipt is carried forward so the upcoming launch's configuration can + // 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, receiptDriftWarnings, cancellationToken); + logger.LogInformation("[GameLauncher] Preparing workspace at: {WorkspacePath}", workspaceConfig.WorkspaceRootPath); var workspaceProgress = new Progress( wp => @@ -1206,6 +1291,17 @@ private async Task> LaunchProfileAsync(Gam 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) + { + var configurationDrift = launchReceiptService.CompareUpcomingLaunch(previousReceipt, receiptContext); + LogReceiptDrift(profile.Id, configurationDrift); + receiptDriftWarnings.AddRange(configurationDrift.DriftedFields); + } + logger.LogInformation("[GameLauncher] Starting game process..."); OperationResult processResult; @@ -1298,6 +1394,8 @@ private async Task> LaunchProfileAsync(Gam var processInfo = processResult.Data; logger.LogInformation("[GameLauncher] Process started successfully - PID: {ProcessId}", processInfo.ProcessId); + await RecordLaunchReceiptAsync(receiptContext); + // Update the placeholder launch entry with real process info // (The placeholder was registered earlier to prevent deletion during launch) var launchInfo = new GameLaunchInfo @@ -1307,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); @@ -1336,6 +1435,85 @@ private async Task> LaunchProfileAsync(Gam } } + /// + /// 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. + /// 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, List driftWarnings, CancellationToken cancellationToken) + { + var driftResult = await launchReceiptService.RevalidateAsync(workspacePath, cancellationToken); + if (!driftResult.Success) + { + logger.LogWarning("[GameLauncher] Launch receipt revalidation failed: {Error}", driftResult.FirstError); + return null; + } + + if (driftResult.Data is not { HasReceipt: true } driftReport) + { + return null; + } + + if (!driftReport.HasDrift) + { + logger.LogDebug("[GameLauncher] Launch receipt for profile {ProfileId} matches the current state", profileId); + } + + LogReceiptDrift(profileId, driftReport); + driftWarnings.AddRange(driftReport.DriftedFields); + 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( + "[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. + /// + /// + /// 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 task representing the asynchronous operation. + private async Task RecordLaunchReceiptAsync(LaunchReceiptContext receiptContext) + { + 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(ex, "[GameLauncher] Failed to record launch receipt for profile {ProfileId}", receiptContext.ProfileId); + } + } + /// /// 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..a3d34458c --- /dev/null +++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs @@ -0,0 +1,620 @@ +using System; +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; +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], + Variant = context.Variant, + }; + + foreach (var (manifestId, version) in context.ManifestVersions) + { + receipt.ManifestVersions[manifestId] = version; + } + + receipt.EnvironmentHashSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + foreach (var (variableName, value) in context.EnvironmentVariables) + { + receipt.EnvironmentVariableHashes[variableName] = + HashEnvironmentValue(value, receipt.EnvironmentHashSalt); + } + + 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); + } + + report.Receipt = receipt; + + // 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 + { + 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); + } + + /// + public LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, LaunchReceiptContext upcoming) + { + ArgumentNullException.ThrowIfNull(receipt); + ArgumentNullException.ThrowIfNull(upcoming); + + var report = new LaunchReceiptDriftReport { HasReceipt = true, Receipt = receipt }; + + // 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 + { + 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 ?? string.Empty, upcoming.ExecutablePath)) + { + report.DriftedFields.Add( + $"Executable path changed from {NameOrNone(receipt.Executable?.Path)} to {upcoming.ExecutablePath}"); + } + + CompareManifests(receipt, upcoming, report); + CompareArchiveRootConfiguration(receipt, upcoming, report); + CompareEnvironment(receipt, upcoming, report); + CompareVariant(receipt.Variant, upcoming.Variant, report); + } + catch (Exception ex) + { + 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})"); + } + + return 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); + + /// + /// 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(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. + /// + /// 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. + /// + /// 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 recordedManifestIds = receipt.ManifestIds ?? []; + var recordedIds = new HashSet(recordedManifestIds, StringComparer.Ordinal); + var upcomingIds = new HashSet(upcoming.ManifestIds, StringComparer.Ordinal); + + foreach (var manifestId in recordedManifestIds) + { + 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}"); + } + } + } + + /// + /// 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) + { + // 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. + var recordedHashes = receipt.EnvironmentVariableHashes ?? []; + foreach (var (variableName, recordedHash) in recordedHashes) + { + if (IsArchiveRootVariable(variableName)) + { + continue; + } + + if (!upcoming.EnvironmentVariables.TryGetValue(variableName, out var upcomingValue)) + { + report.DriftedFields.Add($"Environment variable {variableName} is no longer set"); + } + else if (!string.Equals( + recordedHash, + HashEnvironmentValue(upcomingValue, receipt.EnvironmentHashSalt), + StringComparison.Ordinal)) + { + report.DriftedFields.Add($"Environment variable {variableName} changed value"); + } + } + + foreach (var (variableName, _) in upcoming.EnvironmentVariables) + { + if (!IsArchiveRootVariable(variableName) && + !recordedHashes.ContainsKey(variableName)) + { + 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 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. + /// + /// 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. + 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)) + { + var info = new FileInfo(archivePath); + fingerprint.Archives.Add(new LaunchReceiptArchiveEntry + { + FileName = info.Name, + SizeBytes = info.Length, + LastWriteUtc = info.LastWriteTimeUtc, + }); + fingerprint.ArchiveCount++; + fingerprint.TotalArchiveBytes += info.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, + /// 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. + /// 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; + } + + var recordedByName = IndexArchivesByName(recorded.Archives); + var currentByName = IndexArchivesByName(current.Archives); + + foreach (var (fileName, recordedArchive) in recordedByName) + { + 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}"); + } + } + + foreach (var (fileName, currentArchive) in currentByName) + { + 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. + /// + /// 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; } }