From 5fd5ecdb51b5cd2b6cfcf04c88e03b894e36405f Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:07:32 +0000 Subject: [PATCH 01/26] feat(content): implement manifest installation instructions service and GeneralsOnline EAC registration (#342) --- .../Constants/GeneralsOnlineConstants.cs | 14 + .../Constants/PublisherTypeConstants.cs | 16 + GenHub/GenHub.Core/Helpers/PathHelper.cs | 54 +++ .../IInstallationInstructionsService.cs | 42 ++ .../Manifest/IContentManifestBuilder.cs | 55 ++- .../Models/Enums/InstallationStepKind.cs | 27 ++ .../Models/Manifest/InstallationStep.cs | 34 +- .../Content/BaseContentProviderTests.cs | 88 +++- .../Content/GitHubContentProviderTests.cs | 11 +- .../InstallationInstructionsServiceTests.cs | 351 ++++++++++++++ .../GeneralsOnlineManifestFactoryEacTests.cs | 44 ++ .../Manifest/ContentManifestBuilderTests.cs | 72 +++ .../Helpers/PathHelperTests.cs | 56 +++ .../CommunityOutpostProvider.cs | 3 +- .../ContentDeliverers/FileSystemDeliverer.cs | 2 +- .../ContentDeliverers/HttpContentDeliverer.cs | 2 +- .../AODMapsContentProvider.cs | 4 +- .../ContentProviders/BaseContentProvider.cs | 228 +++++----- .../CNCLabsContentProvider.cs | 5 +- .../LocalFileSystemContentProvider.cs | 3 +- .../ContentProviders/ModDBContentProvider.cs | 5 +- .../GeneralsOnlineManifestFactory.cs | 70 ++- .../GeneralsOnline/GeneralsOnlineProvider.cs | 3 +- .../Services/GitHub/GitHubContentProvider.cs | 5 +- .../InstallationInstructionsService.cs | 429 ++++++++++++++++++ .../Publishers/SuperHackersProvider.cs | 3 +- .../Manifest/ContentManifestBuilder.cs | 90 ++-- .../ContentPipelineModule.cs | 3 + 28 files changed, 1519 insertions(+), 200 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs create mode 100644 GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index dad16f47a..9bc039d00 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -139,4 +139,18 @@ public static class GeneralsOnlineConstants /// Default tags for GameData patch manifests. /// public static readonly string[] GameDataTags = ["patch", "generalsonline"]; + + // ===== Easy Anti-Cheat Installation ===== + + /// Product ID registered with Epic Online Services Easy Anti-Cheat for Generals Online. + public const string EacProductId = "fc1cc0d936424212b645105f084d08b0"; + + /// Setup command passed to EasyAntiCheat_EOS_Setup.exe. + public const string EacInstallCommand = "install"; + + /// Display name for the Easy Anti-Cheat installation step. + public const string EacStepName = "Install Easy Anti-Cheat"; + + /// Status message displayed to the user during Easy Anti-Cheat installation. + public const string EacStatusMessage = "Installing AntiCheat"; } diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs index 27f2cd99a..f6519b372 100644 --- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using GenHub.Core.Extensions.GameInstallations; using GenHub.Core.Models.Enums; @@ -59,6 +61,20 @@ public static class PublisherTypeConstants /// Art of Defense Maps community site. public const string AODMaps = "aodmaps"; + /// GenHub internal system content publisher. + public const string GenHubInternal = "genhub"; + + /// + /// Set of publisher identifiers trusted to execute installation steps (e.g. installers). + /// + public static readonly IReadOnlySet TrustedExecutablePublishers = new HashSet(StringComparer.OrdinalIgnoreCase) + { + GeneralsOnline, + CommunityOutpost, + TheSuperHackers, + GenHubInternal, + }; + /// /// Maps GameInstallationType enum to publisher type string. /// diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index 288f86fe2..8e934757c 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -63,6 +63,42 @@ public static string GetSafeParentDirectory(string path) return string.IsNullOrEmpty(parent) ? path : parent; } + /// + /// Determines whether the candidate path is contained within the specified container directory. + /// Prevents directory traversal and sibling prefix false positives. + /// + /// The candidate file or directory path to check. + /// The directory that must contain the candidate path. + /// if candidatePath resolves inside containerDirectory; otherwise, . + public static bool IsPathContainedIn(string candidatePath, string containerDirectory) + { + if (string.IsNullOrWhiteSpace(candidatePath) || string.IsNullOrWhiteSpace(containerDirectory)) + { + return false; + } + + try + { + var fullCandidate = Path.GetFullPath(candidatePath); + var fullContainer = Path.GetFullPath(containerDirectory); + + if (!fullContainer.EndsWith(Path.DirectorySeparatorChar) && !fullContainer.EndsWith(Path.AltDirectorySeparatorChar)) + { + fullContainer += Path.DirectorySeparatorChar; + } + + return fullCandidate.StartsWith(fullContainer, PathComparison) || + string.Equals( + fullCandidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + fullContainer.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + PathComparison); + } + catch + { + return false; + } + } + /// /// Determines whether a candidate path resolves to a location inside a base directory. /// Both paths are fully normalized first, so .. segments, redundant separators and @@ -83,6 +119,24 @@ public static bool IsPathWithinDirectory(string baseDirectory, string candidateP IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); } + /// + /// Normalizes a relative path by standardizing directory separators and removing leading separators. + /// + /// The relative path to normalize. + /// The normalized relative path. + public static string NormalizeRelativePath(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return string.Empty; + } + + return relativePath + .Replace('\\', '/') + .TrimStart('/') + .Replace('/', Path.DirectorySeparatorChar); + } + private static bool IsContained(string normalizedRoot, string normalizedTarget) { var relative = Path.GetRelativePath(normalizedRoot, normalizedTarget); diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs new file mode 100644 index 000000000..ad650654e --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for validating and executing manifest-declared installation steps. +/// +public interface IInstallationInstructionsService +{ + /// + /// Executes pre-installation steps for the specified manifest. + /// + /// The content manifest declaring pre-installation steps. + /// The working directory containing the content files. + /// Optional progress reporter for acquisition status. + /// A token to cancel the operation. + /// A result indicating whether all pre-installation steps succeeded. + Task ExecutePreInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + /// + /// Executes post-installation steps for the specified manifest. + /// + /// The content manifest declaring post-installation steps. + /// The working directory containing the content files. + /// Optional progress reporter for acquisition status. + /// A token to cancel the operation. + /// A result indicating whether all post-installation steps succeeded. + Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs index e707f019e..acad6ac36 100644 --- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs +++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs @@ -206,27 +206,66 @@ IContentManifestBuilder AddDependency( /// The builder instance for chaining. IContentManifestBuilder WithInstallationInstructions(WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy); + /// + /// Sets the complete installation instructions object for the manifest. + /// + /// The installation instructions object. + /// The builder instance for chaining. + IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions); + /// /// Adds a pre-installation step. /// /// Step name. - /// Command to execute. - /// Command arguments. - /// Working directory for the command. + /// The kind of installation step to execute. + /// Target relative path within workspace. + /// Command arguments for executable steps. + /// Destination relative path for rename operations. /// Whether elevation is required. + /// Optional user-facing status message. + /// The builder instance for chaining. + IContentManifestBuilder AddPreInstallStep( + string name, + InstallationStepKind kind, + string? targetRelativePath = null, + List? arguments = null, + string? destinationRelativePath = null, + bool requiresElevation = false, + string? statusMessage = null); + + /// + /// Adds a pre-installation step using an existing instance. + /// + /// The installation step to add. /// The builder instance for chaining. - IContentManifestBuilder AddPreInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false); + IContentManifestBuilder AddPreInstallStep(InstallationStep step); /// /// Adds a post-installation step. /// /// Step name. - /// Command to execute. - /// Command arguments. - /// Working directory for the command. + /// The kind of installation step to execute. + /// Target relative path within workspace. + /// Command arguments for executable steps. + /// Destination relative path for rename operations. /// Whether elevation is required. + /// Optional user-facing status message. + /// The builder instance for chaining. + IContentManifestBuilder AddPostInstallStep( + string name, + InstallationStepKind kind, + string? targetRelativePath = null, + List? arguments = null, + string? destinationRelativePath = null, + bool requiresElevation = false, + string? statusMessage = null); + + /// + /// Adds a post-installation step using an existing instance. + /// + /// The installation step to add. /// The builder instance for chaining. - IContentManifestBuilder AddPostInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false); + IContentManifestBuilder AddPostInstallStep(InstallationStep step); /// /// Adds a content reference for cross-publisher linking. diff --git a/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs new file mode 100644 index 000000000..9e0bf7827 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs @@ -0,0 +1,27 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Defines the supported kind of installation operation in manifest-declared installation steps. +/// +public enum InstallationStepKind +{ + /// + /// Installation step kind is unknown or undefined (default). + /// + Unknown = 0, + + /// + /// Runs a verified installer executable that exists within the manifest and workspace. + /// + RunVerifiedInstaller = 1, + + /// + /// Removes a file within the workspace. + /// + RemoveFile = 2, + + /// + /// Renames or moves a file within the workspace. + /// + RenameFile = 3, +} diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs index 6ecd505a9..cb8b494a5 100644 --- a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs +++ b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using GenHub.Core.Models.Enums; + namespace GenHub.Core.Models.Manifest; /// -/// Individual installation step with commands and conditions. +/// Individual installation step with typed operation kind and structured parameters. /// public class InstallationStep { @@ -11,22 +15,38 @@ public class InstallationStep public string Name { get; set; } = string.Empty; /// - /// Gets or sets the command to execute. + /// Gets or sets the kind of installation operation to execute. + /// + public InstallationStepKind Kind { get; set; } = InstallationStepKind.Unknown; + + /// + /// Gets or sets the relative path of the target file to act upon in the delivered workspace or manifest. /// - public string Command { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TargetRelativePath { get; set; } /// - /// Gets or sets the arguments for the command. + /// Gets or sets the destination relative path when renaming or moving a file. + /// Only used when is . /// - public List Arguments { get; set; } = new(); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DestinationRelativePath { get; set; } /// - /// Gets or sets the working directory for the command. + /// Gets or sets the arguments for executable steps. + /// Only used when is . /// - public string? WorkingDirectory { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Arguments { get; set; } /// /// Gets or sets a value indicating whether the step requires elevation. /// public bool RequiresElevation { get; set; } + + /// + /// Gets or sets an optional user-facing status message to display in notifications or progress. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StatusMessage { get; set; } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 21b6cd066..4ef6f2ad4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Content; using GenHub.Core.Models.Manifest; @@ -6,6 +10,7 @@ using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; using Moq; +using Xunit; namespace GenHub.Tests.Core.Features.Content; @@ -15,14 +20,15 @@ namespace GenHub.Tests.Core.Features.Content; public class BaseContentProviderTests { /// - /// Verifies that PrepareContentAsync validates manifest before preparation. + /// Verifies that PrepareContentAsync validates manifest before preparation and executes post-install steps. /// /// A task representing the asynchronous operation. [Fact] - public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() + public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallStepsAsync() { // Arrange var validatorMock = new Mock(); + var instructionsMock = new Mock(); var loggerMock = new Mock(); var discovererMock = new Mock(); var resolverMock = new Mock(); @@ -40,7 +46,20 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() }) .ReturnsAsync(validationResult); - var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object); + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); // Act var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); @@ -48,9 +67,54 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() // Assert Assert.True(result.Success); validatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once); + instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", It.IsAny>(), It.IsAny()), Times.Once); validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once); } + /// + /// Verifies that PrepareContentAsync fails when post-install steps fail. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync() + { + // Arrange + var validatorMock = new Mock(); + var instructionsMock = new Mock(); + var loggerMock = new Mock(); + var discovererMock = new Mock(); + var resolverMock = new Mock(); + var delivererMock = new Mock(); + + var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" }; + var validationResult = new ValidationResult(manifest.Id, new List()); + + validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(validationResult); + + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Post-install step execution error")); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); + + // Act + var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); + + // Assert + Assert.False(result.Success); + Assert.Contains("Post-install step execution error", result.FirstError); + } + /// /// Verifies that PrepareContentAsync fails when manifest validation fails with errors. /// @@ -60,6 +124,7 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync( { // Arrange var validatorMock = new Mock(); + var instructionsMock = new Mock(); var loggerMock = new Mock(); var discovererMock = new Mock(); var resolverMock = new Mock(); @@ -75,7 +140,13 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync( validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) .ReturnsAsync(validationResult); - var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object); + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); // Act var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); @@ -96,11 +167,12 @@ private class TestContentProvider : BaseContentProvider public TestContentProvider( IContentValidator validator, + IInstallationInstructionsService instructionsService, ILogger logger, IContentDiscoverer discoverer, IContentResolver resolver, IContentDeliverer deliverer) - : base(validator, logger) + : base(validator, instructionsService, logger) { _discoverer = discoverer; _resolver = resolver; @@ -117,12 +189,6 @@ public TestContentProvider( protected override IContentDeliverer Deliverer => _deliverer; - public override Task> GetValidatedContentAsync(string contentId, CancellationToken cancellationToken = default) - { - var manifest = new ContentManifest { Id = contentId, Name = $"Content {contentId}" }; - return Task.FromResult(OperationResult.CreateSuccess(manifest)); - } - protected override Task> PrepareContentInternalAsync( ContentManifest manifest, string workingDirectory, IProgress? progress, CancellationToken cancellationToken) { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index a50f687ff..00249d770 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -46,12 +46,21 @@ public GitHubContentProviderTests() _validatorMock.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new ValidationResult("test", [])); + var instructionsMock = new Mock(); + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _provider = new GitHubContentProvider( [_discovererMock.Object], [_resolverMock.Object], [_delivererMock.Object], _loggerMock.Object, - _validatorMock.Object); + _validatorMock.Object, + instructionsMock.Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs new file mode 100644 index 000000000..f32fd8e30 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public sealed class InstallationInstructionsServiceTests : IDisposable +{ + private readonly string _tempDirectory; + private readonly Mock _hashProviderMock; + private readonly Mock _notificationServiceMock; + private readonly InstallationInstructionsService _service; + + public InstallationInstructionsServiceTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-inst-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDirectory); + + _hashProviderMock = new Mock(); + _notificationServiceMock = new Mock(); + + _service = new InstallationInstructionsService( + _hashProviderMock.Object, + _notificationServiceMock.Object, + NullLogger.Instance); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignore cleanup error + } + } + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions(); + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_UntrustedPublisher_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = "Untrusted Publisher", + PublisherType = "untrusted_source", + }; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Malicious Executable", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "malicious.exe", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.False(result.Success); + Assert.Contains("not authorized to execute installation steps", result.FirstError); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Traverse Path", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = @"../../outside.exe", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution() + { + var targetFile = "installer.exe"; + var fullPath = Path.Combine(_tempDirectory, targetFile); + File.WriteAllText(fullPath, "binary content"); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = []; // Empty files list - installer not declared + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Undeclared Installer", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = targetFile, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.False(result.Success); + Assert.Contains("not declared in manifest files", result.FirstError); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution() + { + var targetFile = "installer.exe"; + var fullPath = Path.Combine(_tempDirectory, targetFile); + File.WriteAllText(fullPath, "binary content"); + + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync("actual_hash_value"); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = targetFile, + Hash = "expected_different_hash", + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Corrupted Installer", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = targetFile, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.False(result.Success); + Assert.Contains("Integrity verification failed", result.FirstError); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile() + { + var fileToRemove = "temp_cache.tmp"; + var fullPath = Path.Combine(_tempDirectory, fileToRemove); + File.WriteAllText(fullPath, "temporary content"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Remove Cache", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = fileToRemove, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() + { + var sourceFile = "source.txt"; + var destFile = Path.Combine("subfolder", "dest.txt"); + var sourceFullPath = Path.Combine(_tempDirectory, sourceFile); + var destFullPath = Path.Combine(_tempDirectory, destFile); + + File.WriteAllText(sourceFullPath, "hello world"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename File", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = sourceFile, + DestinationRelativePath = destFile, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + Assert.False(File.Exists(sourceFullPath)); + Assert.True(File.Exists(destFullPath)); + Assert.Equal("hello world", File.ReadAllText(destFullPath)); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotification() + { + var scriptName = OperatingSystem.IsWindows() ? "test_installer.bat" : "test_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + var scriptContent = OperatingSystem.IsWindows() ? "@exit 0" : "#!/bin/sh\nexit 0\n"; + File.WriteAllText(fullPath, scriptContent); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + _notificationServiceMock.Verify( + n => n.ShowInfo( + GeneralsOnlineConstants.EacStepName, + GeneralsOnlineConstants.EacStatusMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + _notificationServiceMock.Verify( + n => n.ShowSuccess( + "Installation Step Completed", + It.Is(msg => msg.Contains(GeneralsOnlineConstants.EacStepName)), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Unknown Step", + Kind = InstallationStepKind.Unknown, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.False(result.Success); + Assert.Contains("Unsupported installation step kind", result.FirstError); + } + + private static ContentManifest CreateBaseManifest() => new() + { + Id = "1.0.test.gameclient.variant", + Name = "Test Manifest", + Version = "1.0.0", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs index 04e9b80a9..b0d203ded 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs @@ -129,6 +129,50 @@ public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_MarksSix ignoreCase: true); } + /// + /// Verifies that EAC portable layout configures a post-install step to run the verified EAC setup executable. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_ConfiguresEacPostInstallStepAsync() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + Assert.NotNull(gameClient.InstallationInstructions); + var postSteps = gameClient.InstallationInstructions.PostInstallSteps; + var eacStep = Assert.Single(postSteps); + + Assert.Equal(GeneralsOnlineConstants.EacStepName, eacStep.Name); + Assert.Equal(InstallationStepKind.RunVerifiedInstaller, eacStep.Kind); + Assert.Equal(GameClientConstants.GeneralsOnlineEacSetupExecutable, eacStep.TargetRelativePath); + Assert.True(eacStep.RequiresElevation); + Assert.Equal(GeneralsOnlineConstants.EacStatusMessage, eacStep.StatusMessage); + Assert.NotNull(eacStep.Arguments); + Assert.Equal( + [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + eacStep.Arguments); + } + + /// + /// Verifies that Pre-EAC portable layout does not configure an EAC post-install step when setup executable is absent. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_DoesNotConfigureEacPostInstallStepAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var gameClient = await CreateGameClientManifestAsync(); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)); + Assert.Null(eacStep); + } + /// public void Dispose() { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs index aad8be697..6a9702565 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs @@ -208,6 +208,78 @@ public void WithInstallationInstructions_SetsWorkspaceStrategy() Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy); } + /// + /// Tests that WithInstallationInstructions sets the full installation instructions object. + /// + [Fact] + public void WithInstallationInstructions_SetsCompleteObject() + { + var instructions = new InstallationInstructions + { + WorkspaceStrategy = WorkspaceStrategy.IsolatedDirectory, + DownloadHash = "abc123hash", + PostInstallSteps = + [ + new InstallationStep + { + Name = "Step 1", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "setup.exe", + }, + ], + }; + + var result = _builder + .WithBasicInfo("Test Publisher", "Test Name", "1") + .WithInstallationInstructions(instructions) + .Build(); + + Assert.NotNull(result.InstallationInstructions); + Assert.Equal(WorkspaceStrategy.IsolatedDirectory, result.InstallationInstructions.WorkspaceStrategy); + Assert.Equal("abc123hash", result.InstallationInstructions.DownloadHash); + Assert.Single(result.InstallationInstructions.PostInstallSteps); + Assert.Equal("Step 1", result.InstallationInstructions.PostInstallSteps[0].Name); + } + + /// + /// Tests that AddPostInstallStep adds a structured installation step. + /// + [Fact] + public void AddPostInstallStep_AddsStepCorrectly() + { + var result = _builder + .WithBasicInfo("Test Publisher", "Test Name", "1") + .AddPostInstallStep("EAC Setup", InstallationStepKind.RunVerifiedInstaller, "EasyAntiCheat_EOS_Setup.exe", ["install", "12345"], requiresElevation: true, statusMessage: "Installing AntiCheat") + .Build(); + + Assert.NotNull(result.InstallationInstructions); + var step = Assert.Single(result.InstallationInstructions.PostInstallSteps); + Assert.Equal("EAC Setup", step.Name); + Assert.Equal(InstallationStepKind.RunVerifiedInstaller, step.Kind); + Assert.Equal("EasyAntiCheat_EOS_Setup.exe", step.TargetRelativePath); + Assert.True(step.RequiresElevation); + Assert.Equal("Installing AntiCheat", step.StatusMessage); + Assert.Equal(["install", "12345"], step.Arguments); + } + + /// + /// Tests that AddPreInstallStep adds a structured installation step. + /// + [Fact] + public void AddPreInstallStep_AddsStepCorrectly() + { + var result = _builder + .WithBasicInfo("Test Publisher", "Test Name", "1") + .AddPreInstallStep("Clean Old File", InstallationStepKind.RemoveFile, "old_file.tmp") + .Build(); + + Assert.NotNull(result.InstallationInstructions); + var step = Assert.Single(result.InstallationInstructions.PreInstallSteps); + Assert.Equal("Clean Old File", step.Name); + Assert.Equal(InstallationStepKind.RemoveFile, step.Kind); + Assert.Equal("old_file.tmp", step.TargetRelativePath); + } + /// /// Tests that Build returns a valid manifest with minimal configuration. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index 41e389ba1..5fa3fe505 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -144,6 +144,62 @@ public void IsPathWithinDirectory_AcceptsCandidateBehindASymbolicLinkThatStaysIn } } + /// + /// Verifies that IsPathContainedIn correctly identifies paths inside a container directory. + /// + [Fact] + public void IsPathContainedIn_ReturnsTrue_ForValidDescendants() + { + var container = Path.Combine(Path.GetTempPath(), "GenHubWorkspace"); + var childFile = Path.Combine(container, "subfolder", "file.exe"); + + var result = PathHelper.IsPathContainedIn(childFile, container); + + Assert.True(result); + } + + /// + /// Verifies that IsPathContainedIn returns false when a path attempts directory traversal outside container. + /// + [Fact] + public void IsPathContainedIn_ReturnsFalse_ForPathTraversal() + { + var container = Path.Combine(Path.GetTempPath(), "GenHubWorkspace"); + var escapedFile = Path.Combine(container, "..", "escaped.exe"); + + var result = PathHelper.IsPathContainedIn(escapedFile, container); + + Assert.False(result); + } + + /// + /// Verifies that IsPathContainedIn returns false for sibling directory with common prefix. + /// + [Fact] + public void IsPathContainedIn_ReturnsFalse_ForSiblingDirectoryWithSharedPrefix() + { + var temp = Path.GetTempPath(); + var container = Path.Combine(temp, "GenHubWorkspace"); + var sibling = Path.Combine(temp, "GenHubWorkspaceSibling", "file.exe"); + + var result = PathHelper.IsPathContainedIn(sibling, container); + + Assert.False(result); + } + + /// + /// Verifies that NormalizeRelativePath standardizes path separators. + /// + [Fact] + public void NormalizeRelativePath_StandardizesSeparators() + { + var input = @"folder\subfolder/file.exe"; + var normalized = PathHelper.NormalizeRelativePath(input); + + var expected = Path.Combine("folder", "subfolder", "file.exe"); + Assert.Equal(expected, normalized); + } + private static string CreateWorkingDirectory() { var root = Path.Combine(Path.GetTempPath(), "GenHubContainmentLinks", Guid.NewGuid().ToString("N")); diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs index 3ed1922ae..3f2672c67 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs @@ -32,8 +32,9 @@ public class CommunityOutpostProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IProviderDefinitionLoader _providerDefinitionLoader = providerDefinitionLoader; diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs index 42ee8b423..69b671765 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs @@ -173,7 +173,7 @@ await manifestBuilder.AddContentAddressableFileAsync( // Add installation instructions if present if (packageManifest.InstallationInstructions != null) { - manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy); + manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions); } var deliveredManifest = manifestBuilder.Build(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs index af4ec4273..149278ca7 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs @@ -158,7 +158,7 @@ await deliveredManifest.AddLocalFileAsync( // Add installation instructions if present if (packageManifest.InstallationInstructions != null) { - deliveredManifest.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy); + deliveredManifest.WithInstallationInstructions(packageManifest.InstallationInstructions); } return OperationResult.CreateSuccess(deliveredManifest.Build()); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs index dea3d0047..e071eb54b 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs @@ -21,7 +21,9 @@ public class AODMapsContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _aodMapsDiscoverer = discoverers.FirstOrDefault(d => string.Equals(d.SourceName, AODMapsConstants.DiscovererSourceName, StringComparison.OrdinalIgnoreCase)) diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 0262f9655..8c370faba 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -20,12 +20,10 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// public abstract class BaseContentProvider( IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, ILogger logger ) : IContentProvider { - private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly IContentValidator _contentValidator = contentValidator ?? throw new ArgumentNullException(nameof(contentValidator)); - /// public abstract string SourceName { get; } @@ -58,86 +56,86 @@ public virtual async Task>> Sea $"Discovery failed: {discoveryResult.FirstError}"); } - var resolvedResults = new List(); - - // Step 2: Resolution & Validation - foreach (var discovered in discoveryResult.Data.Items) + // Step 2: Resolution for each discovered item + var results = new List(); + foreach (var manifest in discoveryResult.Data) { - if (discovered.RequiresResolution) - { - var resolutionResult = await Resolver.ResolveAsync(providerDefinition, discovered, cancellationToken); - if (resolutionResult.Success && resolutionResult.Data != null) - { - var validationResult = await ContentValidator.ValidateManifestAsync( - resolutionResult.Data, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); - if (validationResult.IsValid) - { - var resolvedSearchResult = CreateResolvedSearchResult(discovered, resolutionResult.Data); - resolvedResults.Add(resolvedSearchResult); - } - else - { - Logger.LogWarning( - "Manifest validation failed for {ContentName}: {Errors}", - discovered.Name, - string.Join(", ", validationResult.Issues.Select(i => i.Message))); - } - } - else + var resolveResult = await Resolver.ResolveAsync(manifest, cancellationToken); + if (resolveResult.Success && resolveResult.Data != null) + { + results.Add(new ContentSearchResult { - Logger.LogWarning( - "Resolution failed for {ContentName}: {Error}", - discovered.Name, - resolutionResult.FirstError ?? "Unknown error"); - } + Manifest = resolveResult.Data, + SourceName = SourceName, + Score = CalculateRelevanceScore(query.SearchTerm, resolveResult.Data), + }); } else { - resolvedResults.Add(discovered); + Logger.LogWarning("Failed to resolve manifest {ManifestId}: {Error}", manifest.Id, resolveResult.FirstError); } } - return OperationResult>.CreateSuccess(resolvedResults); + Logger.LogInformation("Found {Count} items matching '{SearchTerm}' from {ProviderName}", results.Count, query.SearchTerm, SourceName); + return OperationResult>.CreateSuccess(results); } - /// - /// Gets the manifest for the specified content ID. - /// - /// The content identifier. - /// A token to cancel the operation. - /// A result containing the game manifest. - public abstract Task> GetValidatedContentAsync( + /// + public virtual async Task> GetByIdAsync( string contentId, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + { + Logger.LogDebug("Fetching content by ID: {ContentId} from {ProviderName}", contentId, SourceName); + + var query = new ContentSearchQuery { SearchTerm = contentId }; + var searchResult = await SearchAsync(query, cancellationToken); + + if (!searchResult.Success || searchResult.Data == null) + { + return OperationResult.CreateFailure( + $"Failed to fetch content: {searchResult.FirstError}"); + } + + var match = searchResult.Data.FirstOrDefault(r => r.Manifest.Id.Value == contentId); + if (match?.Manifest == null) + { + return OperationResult.CreateFailure( + $"Content with ID '{contentId}' not found in {SourceName}"); + } - /// + return OperationResult.CreateSuccess(match.Manifest); + } + + /// public virtual async Task> PrepareContentAsync( ContentManifest manifest, string workingDirectory, IProgress? progress = null, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(workingDirectory); + + Logger.LogInformation("Starting content preparation for manifest: {ManifestId} in {Directory}", manifest.Id, workingDirectory); + try { - Logger.LogDebug("Preparing content for manifest {ManifestId}", manifest.Id); - - // Validate manifest before preparation + // Initial manifest structure validation progress?.Report(new ContentAcquisitionProgress { - Phase = ContentAcquisitionPhase.ValidatingManifest, + Phase = ContentAcquisitionPhase.ValidatingFiles, CurrentOperation = "Validating manifest structure...", }); - var validationResult = await ContentValidator.ValidateManifestAsync(manifest, cancellationToken); - if (!validationResult.IsValid) + var manifestValidationResult = await ContentValidator.ValidateManifestAsync(manifest, cancellationToken); + if (manifestValidationResult.HasErrors) { - var errors = validationResult.Issues.Where(i => i.Severity == ValidationSeverity.Error).ToList(); - if (errors.Count > 0) - { - return OperationResult.CreateFailure( - errors.Select(e => $"Manifest validation failed: {e.Message}")); - } + var errors = string.Join("; ", manifestValidationResult.Issues.Where(i => i.Severity == ValidationSeverity.Error).Select(i => i.Message)); + Logger.LogError("Manifest validation failed for {ManifestId}: {Errors}", manifest.Id, errors); + return OperationResult.CreateFailure( + $"Manifest validation failed: {errors}"); } progress?.Report(new ContentAcquisitionProgress @@ -149,8 +147,21 @@ public virtual async Task> PrepareContentAsync( // Delegate to implementation-specific preparation var result = await PrepareContentInternalAsync(manifest, workingDirectory, progress, cancellationToken); - if (result.Success) + if (result.Success && result.Data != null) { + // Execute post-installation steps if declared on the delivered manifest + var stepExecutionResult = await installationInstructionsService.ExecutePostInstallStepsAsync( + result.Data, + workingDirectory, + progress, + cancellationToken); + + if (!stepExecutionResult.Success) + { + Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); + return OperationResult.CreateFailure(stepExecutionResult.Errors); + } + // Final validation of prepared content progress?.Report(new ContentAcquisitionProgress { @@ -178,7 +189,7 @@ public virtual async Task> PrepareContentAsync( var fullResult = await ContentValidator.ValidateAllAsync( workingDirectory, - result.Data!, + result.Data, validationProgress, cancellationToken: cancellationToken); @@ -216,7 +227,12 @@ public virtual async Task> PrepareContentAsync( /// /// Gets the content validator for manifest validation. /// - protected IContentValidator ContentValidator => _contentValidator; + protected IContentValidator ContentValidator => contentValidator; + + /// + /// Gets the installation instructions service. + /// + protected IInstallationInstructionsService InstallationInstructionsService => installationInstructionsService; /// /// Gets the discoverer for this provider. @@ -233,86 +249,60 @@ public virtual async Task> PrepareContentAsync( /// protected abstract IContentDeliverer Deliverer { get; } - /// - /// Gets the provider definition for data-driven configuration. - /// Override this method to provide a ProviderDefinition loaded from JSON configuration. - /// - /// The provider definition, or null if the provider uses hardcoded configuration. - protected virtual ProviderDefinition? GetProviderDefinition() => null; - /// /// Implementation-specific content preparation logic. + /// Override this method to provide custom delivery orchestration. + /// Default implementation uses the Deliverer component. /// - /// The manifest to prepare. - /// Working directory for content preparation. - /// Progress reporter. + /// The content manifest to prepare. + /// The working directory for preparation. + /// Progress reporter for tracking progress. /// Cancellation token. - /// The prepared manifest. - protected abstract Task> PrepareContentInternalAsync( + /// A result containing the prepared manifest with updated file details. + protected virtual async Task> PrepareContentInternalAsync( ContentManifest manifest, string workingDirectory, IProgress? progress, - CancellationToken cancellationToken); + CancellationToken cancellationToken) + { + return await Deliverer.DeliverContentAsync(manifest, workingDirectory, progress, cancellationToken); + } + + /// + /// Gets the provider definition for data-driven configuration. + /// Override in derived classes to provide provider definition from loader. + /// + /// The provider definition, or null if not available. + protected virtual ProviderDefinition? GetProviderDefinition() => null; /// - /// Creates a resolved from a discovered item and manifest. + /// Calculates a simple relevance score for search results. /// - /// The discovered search result. - /// The resolved manifest. - /// A resolved . - private ContentSearchResult CreateResolvedSearchResult(ContentSearchResult discovered, ContentManifest manifest) + private static double CalculateRelevanceScore(string searchTerm, ContentManifest manifest) { - var resolved = new ContentSearchResult + if (string.IsNullOrWhiteSpace(searchTerm)) { - Id = discovered.Id, - Name = manifest.Name, - Description = manifest.Metadata?.Description ?? discovered.Description, - Version = manifest.Version, - ContentType = manifest.ContentType, - TargetGame = manifest.TargetGame, - ProviderName = SourceName, - AuthorName = manifest.Publisher?.Name ?? discovered.AuthorName, - IconUrl = manifest.Metadata?.IconUrl ?? discovered.IconUrl, - LastUpdated = manifest.Metadata?.ReleaseDate ?? discovered.LastUpdated, - DownloadSize = manifest.Files?.Sum(f => f.Size) ?? discovered.DownloadSize, - RequiresResolution = false, - SourceUrl = discovered.SourceUrl, - }; - - // Copy screenshots and tags - resolved.ScreenshotUrls.Clear(); - if (manifest.Metadata?.ScreenshotUrls != null && manifest.Metadata.ScreenshotUrls.Count > 0) - { - foreach (var s in manifest.Metadata.ScreenshotUrls) - { - resolved.ScreenshotUrls.Add(s); - } + return 1.0; } - else + + var score = 0.0; + var term = searchTerm.ToLowerInvariant(); + + if (manifest.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) { - foreach (var s in discovered.ScreenshotUrls) - { - resolved.ScreenshotUrls.Add(s); - } + score += 10.0; } - resolved.Tags.Clear(); - if (manifest.Metadata?.Tags != null && manifest.Metadata.Tags.Count > 0) + if (manifest.Metadata?.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) == true) { - foreach (var t in manifest.Metadata.Tags) - { - resolved.Tags.Add(t); - } + score += 5.0; } - else + + if (manifest.Metadata?.Tags?.Any(t => t.Contains(term, StringComparison.OrdinalIgnoreCase)) == true) { - foreach (var t in discovered.Tags) - { - resolved.Tags.Add(t); - } + score += 3.0; } - resolved.SetData(manifest); - return resolved; + return score; } } diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs index 4c166bb66..8017522e7 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs @@ -21,8 +21,9 @@ public class CNCLabsContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _cncLabsDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.CNCLabsDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new ArgumentException("CNC Labs discoverer not found", nameof(discoverers)); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs index 16300c566..cbbac85a5 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs @@ -24,8 +24,9 @@ public class LocalFileSystemContentProvider( IEnumerable deliverers, ILogger logger, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, IConfigurationProviderService configurationProvider) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _fileSystemDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new InvalidOperationException("No FileSystem discoverer found"); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs index 8595f0d79..089ff48d6 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs @@ -21,8 +21,9 @@ public class ModDBContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _moddbDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.ModDBDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new ArgumentException("ModDB discoverer not found", nameof(discoverers)); diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 59a2eb756..0e74ed32e 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -100,6 +100,22 @@ public ContentManifest CreateVariantManifest( }, ], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), + InstallationInstructions = new InstallationInstructions + { + WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + }, + ], + }, }; } @@ -323,6 +339,7 @@ private ContentManifest CreateGameDataPatchManifest(GeneralsOnlineRelease releas // Files will be populated during extraction Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }; } @@ -378,18 +395,19 @@ private ContentManifest CreateQuickMatchMapPackManifest(GeneralsOnlineRelease re // MapPack requires Zero Hour installation GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(), ], + InstallationInstructions = new InstallationInstructions(), }; } /// - /// Creates all variant manifests (60Hz, MapPack, and GameData Patch) from the original manifest. - /// This is called AFTER extraction - we use the original manifest's metadata to create variants. + /// Creates variant manifests (60Hz, QuickMatch MapPack, and GeneralsOnlineGameData data patch) from an original manifest. + /// This is used after downloading and extracting the portable ZIP. /// - /// The manifest from the Resolver (contains version, publisher info, etc.). - /// List of variant manifests ready for file hash population. + /// The original manifest (can be 60Hz or generic). + /// List of variant manifests with basic information populated. private List CreateVariantManifestsFromOriginal(ContentManifest originalManifest) { - var manifests = new List(); + List manifests = []; var version = originalManifest.Version ?? GeneralsOnlineConstants.UnknownVersion; var userVersion = ParseVersionForManifestId(version); @@ -440,6 +458,22 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), + InstallationInstructions = originalManifest.InstallationInstructions ?? new InstallationInstructions + { + WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + }, + ], + }, }); // Create QuickMatch MapPack @@ -469,6 +503,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest [ GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(), ], + InstallationInstructions = new InstallationInstructions(), }); // Create GeneralsOnlineGameData data patch @@ -495,6 +530,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }); return manifests; @@ -662,6 +698,29 @@ private async Task> UpdateManifestsWithExtractedFiles( $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'."); } + var instructions = new InstallationInstructions + { + WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = manifest.InstallationInstructions?.DownloadHash, + PreInstallSteps = [.. manifest.InstallationInstructions?.PreInstallSteps ?? []], + PostInstallSteps = [.. manifest.InstallationInstructions?.PostInstallSteps ?? []], + }; + + if (manifest.ContentType == ContentType.GameClient && + filesWithHashes.Any(file => !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)) && + !instructions.PostInstallSteps.Any(s => string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) + { + instructions.PostInstallSteps.Add(new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + }); + } + updatedManifests.Add(new ContentManifest { Id = manifest.Id, @@ -673,6 +732,7 @@ private async Task> UpdateManifestsWithExtractedFiles( Metadata = manifest.Metadata, Files = manifestFiles, Dependencies = manifest.Dependencies, + InstallationInstructions = instructions, }); } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index 3c842779c..c4c298346 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -28,9 +28,10 @@ public class GeneralsOnlineProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, IContentManifestPool manifestPool, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private ProviderDefinition? _cachedProviderDefinition; diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs index 4f9358e22..366cb6e41 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs @@ -24,8 +24,9 @@ public class GitHubContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { /// public override string SourceName => "GitHub"; diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs new file mode 100644 index 000000000..e61a9ab16 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -0,0 +1,429 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services; + +/// +/// Service for validating and executing manifest-declared installation steps. +/// Enforces trust boundaries, path containment, and hash verification before execution. +/// +/// The file hash provider for integrity verification. +/// The notification service for user awareness. +/// The logger instance. +public class InstallationInstructionsService( + IFileHashProvider hashProvider, + INotificationService notificationService, + ILogger logger) : IInstallationInstructionsService +{ + /// + public async Task ExecutePreInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (manifest.InstallationInstructions?.PreInstallSteps == null || + manifest.InstallationInstructions.PreInstallSteps.Count == 0) + { + return OperationResult.CreateSuccess(); + } + + logger.LogInformation( + "Executing {Count} pre-install step(s) for manifest {ManifestId}", + manifest.InstallationInstructions.PreInstallSteps.Count, + manifest.Id); + + return await ExecuteStepsAsync( + manifest.InstallationInstructions.PreInstallSteps, + manifest, + workingDirectory, + progress, + cancellationToken); + } + + /// + public async Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (manifest.InstallationInstructions?.PostInstallSteps == null || + manifest.InstallationInstructions.PostInstallSteps.Count == 0) + { + return OperationResult.CreateSuccess(); + } + + logger.LogInformation( + "Executing {Count} post-install step(s) for manifest {ManifestId}", + manifest.InstallationInstructions.PostInstallSteps.Count, + manifest.Id); + + return await ExecuteStepsAsync( + manifest.InstallationInstructions.PostInstallSteps, + manifest, + workingDirectory, + progress, + cancellationToken); + } + + private async Task ExecuteStepsAsync( + IReadOnlyList steps, + ContentManifest manifest, + string workingDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workingDirectory) || !Directory.Exists(workingDirectory)) + { + return OperationResult.CreateFailure($"Working directory does not exist: '{workingDirectory}'"); + } + + for (var i = 0; i < steps.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var step = steps[i]; + + if (step == null) + { + continue; + } + + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, progress, cancellationToken); + if (!stepResult.Success) + { + return stepResult; + } + } + + return OperationResult.CreateSuccess(); + } + + private async Task ExecuteSingleStepAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + switch (step.Kind) + { + case InstallationStepKind.RunVerifiedInstaller: + return await ExecuteRunVerifiedInstallerAsync(step, manifest, workingDirectory, progress, cancellationToken); + + case InstallationStepKind.RemoveFile: + return ExecuteRemoveFile(step, workingDirectory); + + case InstallationStepKind.RenameFile: + return ExecuteRenameFile(step, workingDirectory); + + case InstallationStepKind.Unknown: + default: + logger.LogError("Unsupported installation step kind '{Kind}' in step '{StepName}'", step.Kind, step.Name); + return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); + } + } + + private async Task ExecuteRunVerifiedInstallerAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + // 1. Publisher authorization check + var publisherType = manifest.Publisher?.PublisherType ?? string.Empty; + var publisherName = manifest.Publisher?.Name ?? string.Empty; + + var isTrusted = PublisherTypeConstants.TrustedExecutablePublishers.Contains(publisherType) || + PublisherTypeConstants.TrustedExecutablePublishers.Contains(publisherName); + + if (!isTrusted) + { + logger.LogError( + "Untrusted publisher '{PublisherType}' ({PublisherName}) attempted to execute installer step '{StepName}' for manifest {ManifestId}", + publisherType, + publisherName, + step.Name, + manifest.Id); + + return OperationResult.CreateFailure( + $"Publisher '{(!string.IsNullOrEmpty(publisherType) ? publisherType : publisherName)}' is not authorized to execute installation steps."); + } + + // 2. Target path validation + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for executable step '{step.Name}'."); + } + + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + var targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + + if (!PathHelper.IsPathContainedIn(targetFullPath, workingDirectory)) + { + logger.LogError("Target installer path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Installer path '{step.TargetRelativePath}' escapes the working directory."); + } + + if (!File.Exists(targetFullPath)) + { + logger.LogError("Installer executable not found at '{Path}'", targetFullPath); + return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' was not found in delivered content."); + } + + // 3. Manifest file declaration and integrity verification + var manifestFile = manifest.Files?.FirstOrDefault(f => + string.Equals( + PathHelper.NormalizeRelativePath(f.RelativePath), + normalizedRelativePath, + PathHelper.PathComparison)); + + if (manifestFile == null) + { + logger.LogError("Executable '{Target}' is not declared in manifest files for {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' is not declared in manifest files."); + } + + if (!string.IsNullOrWhiteSpace(manifestFile.Hash)) + { + var computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); + if (!string.Equals(computedHash, manifestFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + logger.LogError( + "Integrity verification failed for installer '{Target}'. Expected: {Expected}, Computed: {Computed}", + step.TargetRelativePath, + manifestFile.Hash, + computedHash); + + return OperationResult.CreateFailure( + $"Integrity verification failed for installer '{step.TargetRelativePath}'."); + } + + logger.LogDebug("Integrity verified for installer '{Target}'", step.TargetRelativePath); + } + + // 4. User notification + var displayTitle = !string.IsNullOrWhiteSpace(step.Name) ? step.Name : "Running Installation Step"; + var displayMessage = !string.IsNullOrWhiteSpace(step.StatusMessage) + ? step.StatusMessage + : $"Executing verified installer '{step.TargetRelativePath}'"; + + notificationService.ShowInfo( + displayTitle, + displayMessage, + NotificationConstants.DefaultAutoDismissMs); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Extracting, + CurrentOperation = displayMessage, + CurrentFile = step.TargetRelativePath, + }); + + // 5. Process execution + logger.LogInformation( + "Executing verified installer '{Target}' (Elevation: {RequiresElevation}) for manifest {ManifestId}", + step.TargetRelativePath, + step.RequiresElevation, + manifest.Id); + + var startInfo = new ProcessStartInfo + { + FileName = targetFullPath, + WorkingDirectory = workingDirectory, + }; + + if (step.Arguments is { Count: > 0 }) + { + foreach (var arg in step.Arguments) + { + startInfo.ArgumentList.Add(arg); + } + } + + if (step.RequiresElevation && OperatingSystem.IsWindows()) + { + startInfo.UseShellExecute = true; + startInfo.Verb = "runas"; + } + else + { + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + } + + try + { + using var process = Process.Start(startInfo); + if (process == null) + { + logger.LogError("Failed to start process for installer '{Target}'", step.TargetRelativePath); + notificationService.ShowError("Installation Step Failed", $"Failed to start installer '{step.Name}'."); + return OperationResult.CreateFailure($"Failed to start installer '{step.TargetRelativePath}'."); + } + + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + logger.LogError( + "Installer step '{StepName}' exited with error code {ExitCode}", + step.Name, + process.ExitCode); + + notificationService.ShowError( + "Installation Step Failed", + $"Step '{step.Name}' failed with exit code {process.ExitCode}."); + + return OperationResult.CreateFailure( + $"Installation step '{step.Name}' failed with exit code {process.ExitCode}."); + } + + logger.LogInformation("Successfully completed installer step '{StepName}'", step.Name); + notificationService.ShowSuccess( + "Installation Step Completed", + $"Successfully completed '{step.Name}'."); + + return OperationResult.CreateSuccess(); + } + catch (OperationCanceledException) + { + logger.LogInformation("Installation step '{StepName}' was canceled", step.Name); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to execute installer step '{StepName}'", step.Name); + notificationService.ShowError( + "Installation Step Error", + $"Error executing '{step.Name}': {ex.Message}"); + + return OperationResult.CreateFailure($"Execution of step '{step.Name}' failed: {ex.Message}"); + } + } + + private OperationResult ExecuteRemoveFile(InstallationStep step, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for remove file step '{step.Name}'."); + } + + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + var targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + + if (!PathHelper.IsPathContainedIn(targetFullPath, workingDirectory)) + { + logger.LogError("Target remove path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Target file '{step.TargetRelativePath}' escapes the working directory."); + } + + try + { + if (File.Exists(targetFullPath)) + { + File.Delete(targetFullPath); + logger.LogInformation("Deleted file '{Target}' as part of step '{StepName}'", step.TargetRelativePath, step.Name); + } + else + { + logger.LogDebug("File '{Target}' already absent during remove step '{StepName}'", step.TargetRelativePath, step.Name); + } + + return OperationResult.CreateSuccess(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete file '{Target}' in step '{StepName}'", step.TargetRelativePath, step.Name); + return OperationResult.CreateFailure($"Failed to delete file '{step.TargetRelativePath}': {ex.Message}"); + } + } + + private OperationResult ExecuteRenameFile(InstallationStep step, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for rename step '{step.Name}'."); + } + + if (string.IsNullOrWhiteSpace(step.DestinationRelativePath)) + { + return OperationResult.CreateFailure($"Destination relative path is required for rename step '{step.Name}'."); + } + + var normalizedSourcePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + var normalizedDestPath = PathHelper.NormalizeRelativePath(step.DestinationRelativePath); + + var sourceFullPath = Path.Combine(workingDirectory, normalizedSourcePath); + var destFullPath = Path.Combine(workingDirectory, normalizedDestPath); + + if (!PathHelper.IsPathContainedIn(sourceFullPath, workingDirectory)) + { + logger.LogError("Source path '{Source}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Source path '{step.TargetRelativePath}' escapes the working directory."); + } + + if (!PathHelper.IsPathContainedIn(destFullPath, workingDirectory)) + { + logger.LogError("Destination path '{Dest}' escapes working directory '{Dir}'", step.DestinationRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Destination path '{step.DestinationRelativePath}' escapes the working directory."); + } + + try + { + if (File.Exists(sourceFullPath)) + { + var destDir = Path.GetDirectoryName(destFullPath); + if (!string.IsNullOrEmpty(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Move(sourceFullPath, destFullPath, overwrite: true); + logger.LogInformation( + "Renamed '{Source}' to '{Dest}' in step '{StepName}'", + step.TargetRelativePath, + step.DestinationRelativePath, + step.Name); + } + else + { + logger.LogWarning("Source file '{Source}' does not exist for rename step '{StepName}'", step.TargetRelativePath, step.Name); + } + + return OperationResult.CreateSuccess(); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to rename '{Source}' to '{Dest}' in step '{StepName}'", + step.TargetRelativePath, + step.DestinationRelativePath, + step.Name); + + return OperationResult.CreateFailure( + $"Failed to rename '{step.TargetRelativePath}' to '{step.DestinationRelativePath}': {ex.Message}"); + } + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index 3f1b78008..e07282293 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -28,8 +28,9 @@ public class SuperHackersProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(SuperHackersConstants.ResolverId, StringComparison.OrdinalIgnoreCase) == true) diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index fcecce36f..515c684e1 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -614,69 +614,87 @@ public IContentManifestBuilder AddRequiredDirectories(params string[] directorie public IContentManifestBuilder WithInstallationInstructions( WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy) { - _manifest.InstallationInstructions = new InstallationInstructions - { - WorkspaceStrategy = workspaceStrategy, - }; + _manifest.InstallationInstructions ??= new InstallationInstructions(); + _manifest.InstallationInstructions.WorkspaceStrategy = workspaceStrategy; logger.LogDebug("Set workspace strategy: {Strategy}", workspaceStrategy); return this; } - /// - /// Adds a pre-installation step to the manifest. - /// - /// Step name. - /// Command. - /// Arguments. - /// Working directory. - /// Requires elevation. - /// The builder instance. + /// + public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions) + { + _manifest.InstallationInstructions = installationInstructions ?? new InstallationInstructions(); + logger.LogDebug( + "Set installation instructions with strategy {Strategy}, {PreCount} pre-install steps, {PostCount} post-install steps", + _manifest.InstallationInstructions.WorkspaceStrategy, + _manifest.InstallationInstructions.PreInstallSteps.Count, + _manifest.InstallationInstructions.PostInstallSteps.Count); + return this; + } + + /// public IContentManifestBuilder AddPreInstallStep( string name, - string command, + InstallationStepKind kind, + string? targetRelativePath = null, List? arguments = null, - string workingDirectory = "", - bool requiresElevation = false) + string? destinationRelativePath = null, + bool requiresElevation = false, + string? statusMessage = null) { var step = new InstallationStep { Name = name, - Command = command, - Arguments = arguments ?? [], - WorkingDirectory = workingDirectory, + Kind = kind, + TargetRelativePath = targetRelativePath, + Arguments = arguments, + DestinationRelativePath = destinationRelativePath, RequiresElevation = requiresElevation, + StatusMessage = statusMessage, }; + return AddPreInstallStep(step); + } + + /// + public IContentManifestBuilder AddPreInstallStep(InstallationStep step) + { + ArgumentNullException.ThrowIfNull(step); + _manifest.InstallationInstructions ??= new InstallationInstructions(); _manifest.InstallationInstructions.PreInstallSteps.Add(step); - logger.LogDebug("Added pre-install step: {StepName}", name); + logger.LogDebug("Added pre-install step: {StepName} (Kind: {Kind})", step.Name, step.Kind); return this; } - /// - /// Adds a post-installation step to the manifest. - /// - /// Step name. - /// Command. - /// Arguments. - /// Working directory. - /// Requires elevation. - /// The builder instance. + /// public IContentManifestBuilder AddPostInstallStep( string name, - string command, + InstallationStepKind kind, + string? targetRelativePath = null, List? arguments = null, - string workingDirectory = "", - bool requiresElevation = false) + string? destinationRelativePath = null, + bool requiresElevation = false, + string? statusMessage = null) { var step = new InstallationStep { Name = name, - Command = command, - Arguments = arguments ?? [], - WorkingDirectory = workingDirectory, + Kind = kind, + TargetRelativePath = targetRelativePath, + Arguments = arguments, + DestinationRelativePath = destinationRelativePath, RequiresElevation = requiresElevation, + StatusMessage = statusMessage, }; + return AddPostInstallStep(step); + } + + /// + public IContentManifestBuilder AddPostInstallStep(InstallationStep step) + { + ArgumentNullException.ThrowIfNull(step); + _manifest.InstallationInstructions ??= new InstallationInstructions(); _manifest.InstallationInstructions.PostInstallSteps.Add(step); - logger.LogDebug("Added post-install step: {StepName}", name); + logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind})", step.Name, step.Kind); return this; } diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index ea7246dec..72c870610 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -361,5 +361,8 @@ private static void AddSharedComponents(IServiceCollection services) // Register content orchestrator and validator services.AddSingleton(); + + // Register installation instructions execution service + services.AddSingleton(); } } From 79b398a61faba5e04637b69c80efb078e50e8d3a Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:18:16 +0000 Subject: [PATCH 02/26] feat(content): add RunOnce step skipping and user settings persistence for update idempotency (#342) --- .../Constants/GeneralsOnlineConstants.cs | 3 + .../IInstallationInstructionsService.cs | 4 + .../Manifest/IContentManifestBuilder.cs | 12 +- .../GenHub.Core/Models/Common/UserSettings.cs | 27 ++++ .../Models/Manifest/InstallationStep.cs | 11 ++ .../InstallationInstructionsServiceTests.cs | 111 ++++++++++++++ .../GeneralsOnlineManifestFactoryEacTests.cs | 2 + .../GeneralsOnlineManifestFactory.cs | 6 + .../InstallationInstructionsService.cs | 144 +++++++++++++++++- .../Manifest/ContentManifestBuilder.cs | 16 +- 10 files changed, 322 insertions(+), 14 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 9bc039d00..f391910b8 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -153,4 +153,7 @@ public static class GeneralsOnlineConstants /// Status message displayed to the user during Easy Anti-Cheat installation. public const string EacStatusMessage = "Installing AntiCheat"; + + /// Unique step key identifying Easy Anti-Cheat installation for Generals Online. + public const string EacStepKey = "generalsonline:eac:fc1cc0d936424212b645105f084d08b0"; } diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs index ad650654e..0469bd657 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -17,12 +17,14 @@ public interface IInstallationInstructionsService /// /// The content manifest declaring pre-installation steps. /// The working directory containing the content files. + /// Whether to force execution of steps marked as run-once even if already executed. /// Optional progress reporter for acquisition status. /// A token to cancel the operation. /// A result indicating whether all pre-installation steps succeeded. Task ExecutePreInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force = false, IProgress? progress = null, CancellationToken cancellationToken = default); @@ -31,12 +33,14 @@ Task ExecutePreInstallStepsAsync( /// /// The content manifest declaring post-installation steps. /// The working directory containing the content files. + /// Whether to force execution of steps marked as run-once even if already executed. /// Optional progress reporter for acquisition status. /// A token to cancel the operation. /// A result indicating whether all post-installation steps succeeded. Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force = false, IProgress? progress = null, CancellationToken cancellationToken = default); } diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs index acad6ac36..1f833d4d8 100644 --- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs +++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs @@ -223,6 +223,8 @@ IContentManifestBuilder AddDependency( /// Destination relative path for rename operations. /// Whether elevation is required. /// Optional user-facing status message. + /// Whether to execute only once and skip on future updates. + /// Optional unique step key for tracking execution. /// The builder instance for chaining. IContentManifestBuilder AddPreInstallStep( string name, @@ -231,7 +233,9 @@ IContentManifestBuilder AddPreInstallStep( List? arguments = null, string? destinationRelativePath = null, bool requiresElevation = false, - string? statusMessage = null); + string? statusMessage = null, + bool runOnce = false, + string? stepKey = null); /// /// Adds a pre-installation step using an existing instance. @@ -250,6 +254,8 @@ IContentManifestBuilder AddPreInstallStep( /// Destination relative path for rename operations. /// Whether elevation is required. /// Optional user-facing status message. + /// Whether to execute only once and skip on future updates. + /// Optional unique step key for tracking execution. /// The builder instance for chaining. IContentManifestBuilder AddPostInstallStep( string name, @@ -258,7 +264,9 @@ IContentManifestBuilder AddPostInstallStep( List? arguments = null, string? destinationRelativePath = null, bool requiresElevation = false, - string? statusMessage = null); + string? statusMessage = null, + bool runOnce = false, + string? stepKey = null); /// /// Adds a post-installation step using an existing instance. diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index c33263307..196f4b1dc 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -95,6 +95,11 @@ public class UserSettings /// public CasConfiguration CasConfiguration { get; set; } = new(); + /// + /// Gets or sets the collection of installation step keys that have been executed on this machine. + /// + public HashSet ExecutedInstallationSteps { get; set; } = []; + /// Marks a property as explicitly set by the user. /// The name of the property to mark as explicitly set. public void MarkAsExplicitlySet(string propertyName) @@ -102,6 +107,28 @@ public void MarkAsExplicitlySet(string propertyName) ExplicitlySetProperties.Add(propertyName); } + /// + /// Checks whether an installation step key has already been recorded as executed. + /// + /// The unique installation step key. + /// if already executed; otherwise, . + public bool IsInstallationStepExecuted(string stepKey) + { + return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps.Contains(stepKey); + } + + /// + /// Records that an installation step key has been executed. + /// + /// The unique installation step key. + public void RecordInstallationStepExecuted(string stepKey) + { + if (!string.IsNullOrWhiteSpace(stepKey)) + { + ExecutedInstallationSteps.Add(stepKey); + } + } + /// Checks if a property was explicitly set by the user. /// The name of the property to check. /// true if the property was explicitly set by the user; otherwise, false. diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs index cb8b494a5..78590ebfd 100644 --- a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs +++ b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs @@ -49,4 +49,15 @@ public class InstallationStep /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? StatusMessage { get; set; } + + /// + /// Gets or sets an optional unique key identifying this installation step for execution tracking across updates. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StepKey { get; set; } + + /// + /// Gets or sets a value indicating whether this step should only run once and be skipped on subsequent updates if already executed. + /// + public bool RunOnce { get; set; } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index f32fd8e30..98362c1f6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -6,6 +6,7 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Common; using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; @@ -24,6 +25,8 @@ public sealed class InstallationInstructionsServiceTests : IDisposable private readonly string _tempDirectory; private readonly Mock _hashProviderMock; private readonly Mock _notificationServiceMock; + private readonly Mock _userSettingsServiceMock; + private readonly UserSettings _userSettings; private readonly InstallationInstructionsService _service; public InstallationInstructionsServiceTests() @@ -33,10 +36,17 @@ public InstallationInstructionsServiceTests() _hashProviderMock = new Mock(); _notificationServiceMock = new Mock(); + _userSettingsServiceMock = new Mock(); + _userSettings = new UserSettings(); + + _userSettingsServiceMock.Setup(u => u.Get()).Returns(_userSettings); + _userSettingsServiceMock.Setup(u => u.Update(It.IsAny>())) + .Callback>(action => action(_userSettings)); _service = new InstallationInstructionsService( _hashProviderMock.Object, _notificationServiceMock.Object, + _userSettingsServiceMock.Object, NullLogger.Instance); } @@ -247,6 +257,7 @@ public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() Kind = InstallationStepKind.RenameFile, TargetRelativePath = sourceFile, DestinationRelativePath = destFile, + StepKey = "test_rename_step", }, ], }; @@ -257,6 +268,7 @@ public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() Assert.False(File.Exists(sourceFullPath)); Assert.True(File.Exists(destFullPath)); Assert.Equal("hello world", File.ReadAllText(destFullPath)); + Assert.True(_userSettings.IsInstallationStepExecuted("test_rename_step")); } [Fact] @@ -295,6 +307,8 @@ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotific Kind = InstallationStepKind.RunVerifiedInstaller, TargetRelativePath = scriptName, StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, }, ], }; @@ -302,6 +316,7 @@ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotific var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); Assert.True(result.Success); + Assert.True(_userSettings.IsInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey)); _notificationServiceMock.Verify( n => n.ShowInfo( GeneralsOnlineConstants.EacStepName, @@ -318,6 +333,102 @@ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotific Times.Once); } + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsExecution() + { + var scriptName = "installer.bat"; + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile { RelativePath = scriptName }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + // Mark as already executed + _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + // Notification should NOT be shown for skipped step + _notificationServiceMock.Verify( + n => n.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_ExecutesEvenIfRecorded() + { + var scriptName = OperatingSystem.IsWindows() ? "test_force_installer.bat" : "test_force_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + var scriptContent = OperatingSystem.IsWindows() ? "@exit 0" : "#!/bin/sh\nexit 0\n"; + File.WriteAllText(fullPath, scriptContent); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile { RelativePath = scriptName }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + // Mark as already executed in settings + _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); + + // Force execution + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, force: true); + + Assert.True(result.Success); + _notificationServiceMock.Verify( + n => n.ShowInfo( + GeneralsOnlineConstants.EacStepName, + GeneralsOnlineConstants.EacStatusMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + } + [Fact] public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure() { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs index b0d203ded..bf53abe47 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs @@ -148,6 +148,8 @@ public async Task CreateManifestsFromExtractedContentAsync_EacLayout_ConfiguresE Assert.Equal(InstallationStepKind.RunVerifiedInstaller, eacStep.Kind); Assert.Equal(GameClientConstants.GeneralsOnlineEacSetupExecutable, eacStep.TargetRelativePath); Assert.True(eacStep.RequiresElevation); + Assert.True(eacStep.RunOnce); + Assert.Equal(GeneralsOnlineConstants.EacStepKey, eacStep.StepKey); Assert.Equal(GeneralsOnlineConstants.EacStatusMessage, eacStep.StatusMessage); Assert.NotNull(eacStep.Arguments); Assert.Equal( diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 0e74ed32e..b95322d35 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -113,6 +113,8 @@ public ContentManifest CreateVariantManifest( Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], RequiresElevation = true, StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, }, ], }, @@ -471,6 +473,8 @@ private List CreateVariantManifestsFromOriginal(ContentManifest Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], RequiresElevation = true, StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, }, ], }, @@ -718,6 +722,8 @@ private async Task> UpdateManifestsWithExtractedFiles( Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], RequiresElevation = true, StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, }); } diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index e61a9ab16..3fce68f71 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -24,16 +24,19 @@ namespace GenHub.Features.Content.Services; /// /// The file hash provider for integrity verification. /// The notification service for user awareness. +/// The user settings service for tracking executed installation steps across updates. /// The logger instance. public class InstallationInstructionsService( IFileHashProvider hashProvider, INotificationService notificationService, + IUserSettingsService? userSettingsService, ILogger logger) : IInstallationInstructionsService { /// public async Task ExecutePreInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force = false, IProgress? progress = null, CancellationToken cancellationToken = default) { @@ -46,14 +49,16 @@ public async Task ExecutePreInstallStepsAsync( } logger.LogInformation( - "Executing {Count} pre-install step(s) for manifest {ManifestId}", + "Executing {Count} pre-install step(s) for manifest {ManifestId} (force: {Force})", manifest.InstallationInstructions.PreInstallSteps.Count, - manifest.Id); + manifest.Id, + force); return await ExecuteStepsAsync( manifest.InstallationInstructions.PreInstallSteps, manifest, workingDirectory, + force, progress, cancellationToken); } @@ -62,6 +67,7 @@ public async Task ExecutePreInstallStepsAsync( public async Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force = false, IProgress? progress = null, CancellationToken cancellationToken = default) { @@ -74,14 +80,16 @@ public async Task ExecutePostInstallStepsAsync( } logger.LogInformation( - "Executing {Count} post-install step(s) for manifest {ManifestId}", + "Executing {Count} post-install step(s) for manifest {ManifestId} (force: {Force})", manifest.InstallationInstructions.PostInstallSteps.Count, - manifest.Id); + manifest.Id, + force); return await ExecuteStepsAsync( manifest.InstallationInstructions.PostInstallSteps, manifest, workingDirectory, + force, progress, cancellationToken); } @@ -90,6 +98,7 @@ private async Task ExecuteStepsAsync( IReadOnlyList steps, ContentManifest manifest, string workingDirectory, + bool force, IProgress? progress, CancellationToken cancellationToken) { @@ -108,7 +117,7 @@ private async Task ExecuteStepsAsync( continue; } - var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, progress, cancellationToken); + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, force, progress, cancellationToken); if (!stepResult.Success) { return stepResult; @@ -122,25 +131,144 @@ private async Task ExecuteSingleStepAsync( InstallationStep step, ContentManifest manifest, string workingDirectory, + bool force, IProgress? progress, CancellationToken cancellationToken) { + var stepKey = GetStepKey(step, manifest); + + if (!force && step.RunOnce && ShouldSkipStep(step, stepKey)) + { + logger.LogInformation( + "Skipping installation step '{StepName}' for manifest {ManifestId} because it has already been executed (key: {StepKey})", + step.Name, + manifest.Id, + stepKey); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Extracting, + CurrentOperation = $"Skipping {step.Name} (already installed)", + CurrentFile = step.TargetRelativePath, + }); + + return OperationResult.CreateSuccess(); + } + + OperationResult result; switch (step.Kind) { case InstallationStepKind.RunVerifiedInstaller: - return await ExecuteRunVerifiedInstallerAsync(step, manifest, workingDirectory, progress, cancellationToken); + result = await ExecuteRunVerifiedInstallerAsync(step, manifest, workingDirectory, progress, cancellationToken); + break; case InstallationStepKind.RemoveFile: - return ExecuteRemoveFile(step, workingDirectory); + result = ExecuteRemoveFile(step, workingDirectory); + break; case InstallationStepKind.RenameFile: - return ExecuteRenameFile(step, workingDirectory); + result = ExecuteRenameFile(step, workingDirectory); + break; case InstallationStepKind.Unknown: default: logger.LogError("Unsupported installation step kind '{Kind}' in step '{StepName}'", step.Kind, step.Name); return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); } + + if (result.Success && userSettingsService != null && !string.IsNullOrWhiteSpace(stepKey)) + { + userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); + try + { + await userSettingsService.SaveAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist executed installation step key '{StepKey}'", stepKey); + } + } + + return result; + } + + private bool ShouldSkipStep(InstallationStep step, string stepKey) + { + if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true) + { + return true; + } + + if (OperatingSystem.IsWindows() && IsEacAlreadyInstalled(step)) + { + userSettingsService?.Update(s => s.RecordInstallationStepExecuted(stepKey)); + return true; + } + + return false; + } + + private static bool IsEacAlreadyInstalled(InstallationStep step) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + if (step.Kind != InstallationStepKind.RunVerifiedInstaller) + { + return false; + } + + var fileName = Path.GetFileName(step.TargetRelativePath ?? string.Empty); + if (!string.Equals(fileName, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + try + { + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + if (!string.IsNullOrEmpty(programFilesX86)) + { + var serviceExe = Path.Combine(programFilesX86, "EasyAntiCheat_EOS", "EasyAntiCheat_EOS.exe"); + if (File.Exists(serviceExe)) + { + return true; + } + } + + var commonProgramFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFilesX86); + if (!string.IsNullOrEmpty(commonProgramFilesX86)) + { + var commonExe = Path.Combine(commonProgramFilesX86, "EasyAntiCheat", "EasyAntiCheat_EOS.exe"); + if (File.Exists(commonExe)) + { + return true; + } + } + } + catch + { + return false; + } + + return false; + } + + private static string GetStepKey(InstallationStep step, ContentManifest manifest) + { + if (!string.IsNullOrWhiteSpace(step.StepKey)) + { + return step.StepKey; + } + + var publisher = manifest.Publisher?.PublisherType ?? "generic"; + var name = step.Name; + var target = step.TargetRelativePath ?? string.Empty; + var args = step.Arguments is { Count: > 0 } ? string.Join(" ", step.Arguments) : string.Empty; + + return $"{publisher}:{name}:{target}:{args}".TrimEnd(':'); } private async Task ExecuteRunVerifiedInstallerAsync( diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index 515c684e1..de5a1f162 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -640,7 +640,9 @@ public IContentManifestBuilder AddPreInstallStep( List? arguments = null, string? destinationRelativePath = null, bool requiresElevation = false, - string? statusMessage = null) + string? statusMessage = null, + bool runOnce = false, + string? stepKey = null) { var step = new InstallationStep { @@ -651,6 +653,8 @@ public IContentManifestBuilder AddPreInstallStep( DestinationRelativePath = destinationRelativePath, RequiresElevation = requiresElevation, StatusMessage = statusMessage, + RunOnce = runOnce, + StepKey = stepKey, }; return AddPreInstallStep(step); } @@ -661,7 +665,7 @@ public IContentManifestBuilder AddPreInstallStep(InstallationStep step) ArgumentNullException.ThrowIfNull(step); _manifest.InstallationInstructions ??= new InstallationInstructions(); _manifest.InstallationInstructions.PreInstallSteps.Add(step); - logger.LogDebug("Added pre-install step: {StepName} (Kind: {Kind})", step.Name, step.Kind); + logger.LogDebug("Added pre-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce); return this; } @@ -673,7 +677,9 @@ public IContentManifestBuilder AddPostInstallStep( List? arguments = null, string? destinationRelativePath = null, bool requiresElevation = false, - string? statusMessage = null) + string? statusMessage = null, + bool runOnce = false, + string? stepKey = null) { var step = new InstallationStep { @@ -684,6 +690,8 @@ public IContentManifestBuilder AddPostInstallStep( DestinationRelativePath = destinationRelativePath, RequiresElevation = requiresElevation, StatusMessage = statusMessage, + RunOnce = runOnce, + StepKey = stepKey, }; return AddPostInstallStep(step); } @@ -694,7 +702,7 @@ public IContentManifestBuilder AddPostInstallStep(InstallationStep step) ArgumentNullException.ThrowIfNull(step); _manifest.InstallationInstructions ??= new InstallationInstructions(); _manifest.InstallationInstructions.PostInstallSteps.Add(step); - logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind})", step.Name, step.Kind); + logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce); return this; } From 403a025d57aa5de4cb5c3065ab7cc9fea798ccf5 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:24:14 +0000 Subject: [PATCH 03/26] fix(content): resolve GetValidatedContentAsync interface implementation and DeepSource switch finding --- .../Content/Services/ContentProviders/BaseContentProvider.cs | 2 +- .../Content/Services/InstallationInstructionsService.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 8c370faba..ff912e299 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -83,7 +83,7 @@ public virtual async Task>> Sea } /// - public virtual async Task> GetByIdAsync( + public virtual async Task> GetValidatedContentAsync( string contentId, CancellationToken cancellationToken = default) { diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 3fce68f71..271d0bdf9 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -170,7 +170,6 @@ private async Task ExecuteSingleStepAsync( result = ExecuteRenameFile(step, workingDirectory); break; - case InstallationStepKind.Unknown: default: logger.LogError("Unsupported installation step kind '{Kind}' in step '{StepName}'", step.Kind, step.Name); return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); From dffa05fc19ccdfe2ef1ca7687f5785b3d6ca5bc2 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:26:45 +0000 Subject: [PATCH 04/26] fix(content): align BaseContentProvider pipeline and fix StyleCop warnings --- .../IInstallationInstructionsService.cs | 8 +- .../ContentProviders/BaseContentProvider.cs | 207 ++++++++++-------- .../InstallationInstructionsService.cs | 12 +- 3 files changed, 121 insertions(+), 106 deletions(-) diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs index 0469bd657..ba3e77377 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -17,15 +17,15 @@ public interface IInstallationInstructionsService /// /// The content manifest declaring pre-installation steps. /// The working directory containing the content files. - /// Whether to force execution of steps marked as run-once even if already executed. /// Optional progress reporter for acquisition status. + /// Whether to force execution of steps marked as run-once even if already executed. /// A token to cancel the operation. /// A result indicating whether all pre-installation steps succeeded. Task ExecutePreInstallStepsAsync( ContentManifest manifest, string workingDirectory, - bool force = false, IProgress? progress = null, + bool force = false, CancellationToken cancellationToken = default); /// @@ -33,14 +33,14 @@ Task ExecutePreInstallStepsAsync( /// /// The content manifest declaring post-installation steps. /// The working directory containing the content files. - /// Whether to force execution of steps marked as run-once even if already executed. /// Optional progress reporter for acquisition status. + /// Whether to force execution of steps marked as run-once even if already executed. /// A token to cancel the operation. /// A result indicating whether all post-installation steps succeeded. Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, - bool force = false, IProgress? progress = null, + bool force = false, CancellationToken cancellationToken = default); } diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index ff912e299..a28547050 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -56,86 +56,81 @@ public virtual async Task>> Sea $"Discovery failed: {discoveryResult.FirstError}"); } - // Step 2: Resolution for each discovered item - var results = new List(); - foreach (var manifest in discoveryResult.Data) - { - cancellationToken.ThrowIfCancellationRequested(); + var resolvedResults = new List(); - var resolveResult = await Resolver.ResolveAsync(manifest, cancellationToken); - if (resolveResult.Success && resolveResult.Data != null) + // Step 2: Resolution & Validation + foreach (var discovered in discoveryResult.Data.Items) + { + if (discovered.RequiresResolution) { - results.Add(new ContentSearchResult + var resolutionResult = await Resolver.ResolveAsync(providerDefinition, discovered, cancellationToken); + if (resolutionResult.Success && resolutionResult.Data != null) { - Manifest = resolveResult.Data, - SourceName = SourceName, - Score = CalculateRelevanceScore(query.SearchTerm, resolveResult.Data), - }); + var validationResult = await ContentValidator.ValidateManifestAsync( + resolutionResult.Data, cancellationToken); + + if (validationResult.IsValid) + { + var resolvedSearchResult = CreateResolvedSearchResult(discovered, resolutionResult.Data); + resolvedResults.Add(resolvedSearchResult); + } + else + { + Logger.LogWarning( + "Manifest validation failed for {ContentName}: {Errors}", + discovered.Name, + string.Join(", ", validationResult.Issues.Select(i => i.Message))); + } + } + else + { + Logger.LogWarning( + "Resolution failed for {ContentName}: {Error}", + discovered.Name, + resolutionResult.FirstError); + } } else { - Logger.LogWarning("Failed to resolve manifest {ManifestId}: {Error}", manifest.Id, resolveResult.FirstError); + resolvedResults.Add(discovered); } } - Logger.LogInformation("Found {Count} items matching '{SearchTerm}' from {ProviderName}", results.Count, query.SearchTerm, SourceName); - return OperationResult>.CreateSuccess(results); + return OperationResult>.CreateSuccess(resolvedResults); } - /// - public virtual async Task> GetValidatedContentAsync( + /// + public abstract Task> GetValidatedContentAsync( string contentId, - CancellationToken cancellationToken = default) - { - Logger.LogDebug("Fetching content by ID: {ContentId} from {ProviderName}", contentId, SourceName); + CancellationToken cancellationToken = default); - var query = new ContentSearchQuery { SearchTerm = contentId }; - var searchResult = await SearchAsync(query, cancellationToken); - - if (!searchResult.Success || searchResult.Data == null) - { - return OperationResult.CreateFailure( - $"Failed to fetch content: {searchResult.FirstError}"); - } - - var match = searchResult.Data.FirstOrDefault(r => r.Manifest.Id.Value == contentId); - if (match?.Manifest == null) - { - return OperationResult.CreateFailure( - $"Content with ID '{contentId}' not found in {SourceName}"); - } - - return OperationResult.CreateSuccess(match.Manifest); - } - - /// + /// public virtual async Task> PrepareContentAsync( ContentManifest manifest, string workingDirectory, IProgress? progress = null, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(manifest); - ArgumentNullException.ThrowIfNull(workingDirectory); - - Logger.LogInformation("Starting content preparation for manifest: {ManifestId} in {Directory}", manifest.Id, workingDirectory); - try { - // Initial manifest structure validation + Logger.LogDebug("Preparing content for manifest {ManifestId}", manifest.Id); + + // Validate manifest before preparation progress?.Report(new ContentAcquisitionProgress { - Phase = ContentAcquisitionPhase.ValidatingFiles, + Phase = ContentAcquisitionPhase.ValidatingManifest, CurrentOperation = "Validating manifest structure...", }); - var manifestValidationResult = await ContentValidator.ValidateManifestAsync(manifest, cancellationToken); - if (manifestValidationResult.HasErrors) + var validationResult = await ContentValidator.ValidateManifestAsync(manifest, cancellationToken); + if (!validationResult.IsValid) { - var errors = string.Join("; ", manifestValidationResult.Issues.Where(i => i.Severity == ValidationSeverity.Error).Select(i => i.Message)); - Logger.LogError("Manifest validation failed for {ManifestId}: {Errors}", manifest.Id, errors); - return OperationResult.CreateFailure( - $"Manifest validation failed: {errors}"); + var errors = validationResult.Issues.Where(i => i.Severity == ValidationSeverity.Error).ToList(); + if (errors.Count > 0) + { + return OperationResult.CreateFailure( + errors.Select(e => $"Manifest validation failed: {e.Message}")); + } } progress?.Report(new ContentAcquisitionProgress @@ -153,8 +148,8 @@ public virtual async Task> PrepareContentAsync( var stepExecutionResult = await installationInstructionsService.ExecutePostInstallStepsAsync( result.Data, workingDirectory, - progress, - cancellationToken); + progress: progress, + cancellationToken: cancellationToken); if (!stepExecutionResult.Success) { @@ -195,13 +190,7 @@ public virtual async Task> PrepareContentAsync( if (!fullResult.IsValid) { - // Log as warning only - content may have been moved to CAS already - // CAS storage validates content hash on store, so this is informational Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); - foreach (var issue in fullResult.Issues.Take(5)) - { - Logger.LogDebug("Validation issue: {Message}", issue.Message); - } } } @@ -230,7 +219,7 @@ public virtual async Task> PrepareContentAsync( protected IContentValidator ContentValidator => contentValidator; /// - /// Gets the installation instructions service. + /// Gets the installation instructions service for post-install execution. /// protected IInstallationInstructionsService InstallationInstructionsService => installationInstructionsService; @@ -249,60 +238,86 @@ public virtual async Task> PrepareContentAsync( /// protected abstract IContentDeliverer Deliverer { get; } + /// + /// Gets the provider definition for data-driven configuration. + /// Override this method to provide a ProviderDefinition loaded from JSON configuration. + /// + /// The provider definition, or null if the provider uses hardcoded configuration. + protected virtual ProviderDefinition? GetProviderDefinition() => null; + /// /// Implementation-specific content preparation logic. - /// Override this method to provide custom delivery orchestration. - /// Default implementation uses the Deliverer component. /// - /// The content manifest to prepare. - /// The working directory for preparation. - /// Progress reporter for tracking progress. + /// The manifest to prepare. + /// Working directory for content preparation. + /// Progress reporter. /// Cancellation token. - /// A result containing the prepared manifest with updated file details. - protected virtual async Task> PrepareContentInternalAsync( + /// The prepared manifest. + protected abstract Task> PrepareContentInternalAsync( ContentManifest manifest, string workingDirectory, IProgress? progress, - CancellationToken cancellationToken) - { - return await Deliverer.DeliverContentAsync(manifest, workingDirectory, progress, cancellationToken); - } - - /// - /// Gets the provider definition for data-driven configuration. - /// Override in derived classes to provide provider definition from loader. - /// - /// The provider definition, or null if not available. - protected virtual ProviderDefinition? GetProviderDefinition() => null; + CancellationToken cancellationToken); /// - /// Calculates a simple relevance score for search results. + /// Creates a resolved from a discovered item and manifest. /// - private static double CalculateRelevanceScore(string searchTerm, ContentManifest manifest) + /// The discovered search result. + /// The resolved manifest. + /// A resolved . + private ContentSearchResult CreateResolvedSearchResult(ContentSearchResult discovered, ContentManifest manifest) { - if (string.IsNullOrWhiteSpace(searchTerm)) + var resolved = new ContentSearchResult { - return 1.0; + Id = discovered.Id, + Name = manifest.Name, + Description = manifest.Metadata?.Description ?? discovered.Description, + Version = manifest.Version, + ContentType = manifest.ContentType, + TargetGame = manifest.TargetGame, + ProviderName = SourceName, + AuthorName = manifest.Publisher?.Name ?? discovered.AuthorName, + IconUrl = manifest.Metadata?.IconUrl ?? discovered.IconUrl, + LastUpdated = manifest.Metadata?.ReleaseDate ?? discovered.LastUpdated, + DownloadSize = manifest.Files?.Sum(f => f.Size) ?? discovered.DownloadSize, + RequiresResolution = false, + SourceUrl = discovered.SourceUrl, + }; + + // Copy screenshots and tags + resolved.ScreenshotUrls.Clear(); + if (manifest.Metadata?.ScreenshotUrls != null && manifest.Metadata.ScreenshotUrls.Count > 0) + { + foreach (var s in manifest.Metadata.ScreenshotUrls) + { + resolved.ScreenshotUrls.Add(s); + } } - - var score = 0.0; - var term = searchTerm.ToLowerInvariant(); - - if (manifest.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) + else { - score += 10.0; + foreach (var s in discovered.ScreenshotUrls) + { + resolved.ScreenshotUrls.Add(s); + } } - if (manifest.Metadata?.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) == true) + resolved.Tags.Clear(); + if (manifest.Metadata?.Tags != null && manifest.Metadata.Tags.Count > 0) { - score += 5.0; + foreach (var t in manifest.Metadata.Tags) + { + resolved.Tags.Add(t); + } } - - if (manifest.Metadata?.Tags?.Any(t => t.Contains(term, StringComparison.OrdinalIgnoreCase)) == true) + else { - score += 3.0; + foreach (var t in discovered.Tags) + { + resolved.Tags.Add(t); + } } - return score; + resolved.SetData(manifest); + return resolved; } } diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 271d0bdf9..3514bc777 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -36,8 +36,8 @@ public class InstallationInstructionsService( public async Task ExecutePreInstallStepsAsync( ContentManifest manifest, string workingDirectory, - bool force = false, IProgress? progress = null, + bool force = false, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(manifest); @@ -67,8 +67,8 @@ public async Task ExecutePreInstallStepsAsync( public async Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, - bool force = false, IProgress? progress = null, + bool force = false, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(manifest); @@ -149,7 +149,7 @@ private async Task ExecuteSingleStepAsync( { Phase = ContentAcquisitionPhase.Extracting, CurrentOperation = $"Skipping {step.Name} (already installed)", - CurrentFile = step.TargetRelativePath, + CurrentFile = step.TargetRelativePath ?? string.Empty, }); return OperationResult.CreateSuccess(); @@ -207,7 +207,7 @@ private bool ShouldSkipStep(InstallationStep step, string stepKey) return false; } - private static bool IsEacAlreadyInstalled(InstallationStep step) + private bool IsEacAlreadyInstalled(InstallationStep step) { if (!OperatingSystem.IsWindows()) { @@ -255,7 +255,7 @@ private static bool IsEacAlreadyInstalled(InstallationStep step) return false; } - private static string GetStepKey(InstallationStep step, ContentManifest manifest) + private string GetStepKey(InstallationStep step, ContentManifest manifest) { if (!string.IsNullOrWhiteSpace(step.StepKey)) { @@ -364,7 +364,7 @@ private async Task ExecuteRunVerifiedInstallerAsync( { Phase = ContentAcquisitionPhase.Extracting, CurrentOperation = displayMessage, - CurrentFile = step.TargetRelativePath, + CurrentFile = step.TargetRelativePath ?? string.Empty, }); // 5. Process execution From b637a5523f1fb9c634feb0a0e4c7c4808d2d5d21 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:35:41 +0000 Subject: [PATCH 05/26] fix(content): resolve review feedback, add precondition abstraction, and fix test fixtures --- .../Constants/GeneralsOnlineConstants.cs | 30 ++-- GenHub/GenHub.Core/Helpers/PathHelper.cs | 28 +++- .../Content/IInstallationStepPrecondition.cs | 27 ++++ .../GenHub.Core/Models/Common/UserSettings.cs | 1 + .../Content/BaseContentProviderTests.cs | 16 ++- .../InstallationInstructionsServiceTests.cs | 94 ++++++++++-- .../CommunityOutpostProvider.cs | 1 + .../ContentDeliverers/FileSystemDeliverer.cs | 3 +- .../ContentDeliverers/HttpContentDeliverer.cs | 30 ++-- .../EasyAntiCheatPrecondition.cs | 69 +++++++++ .../GeneralsOnlineManifestFactory.cs | 16 +-- .../InstallationInstructionsService.cs | 136 ++++++++++-------- .../ContentPipelineModule.cs | 3 + 13 files changed, 341 insertions(+), 113 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs create mode 100644 GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index f391910b8..306800947 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -125,21 +125,6 @@ public static class GeneralsOnlineConstants /// Description for Generals Online deliverer. public const string DelivererDescription = "Delivers Generals Online content via ZIP extraction and CAS storage"; - // ===== Content Tags ===== - - /// Content tags for search and categorization. - public static readonly string[] Tags = ["multiplayer", "online", "community", "enhancement"]; - - /// - /// Default tags for MapPack manifests. - /// - public static readonly string[] MapPackTags = ["mappack", "generalsonline", "quickmatch", "competitive"]; - - /// - /// Default tags for GameData patch manifests. - /// - public static readonly string[] GameDataTags = ["patch", "generalsonline"]; - // ===== Easy Anti-Cheat Installation ===== /// Product ID registered with Epic Online Services Easy Anti-Cheat for Generals Online. @@ -156,4 +141,19 @@ public static class GeneralsOnlineConstants /// Unique step key identifying Easy Anti-Cheat installation for Generals Online. public const string EacStepKey = "generalsonline:eac:fc1cc0d936424212b645105f084d08b0"; + + // ===== Content Tags ===== + + /// Content tags for search and categorization. + public static readonly string[] Tags = ["multiplayer", "online", "community", "enhancement"]; + + /// + /// Default tags for MapPack manifests. + /// + public static readonly string[] MapPackTags = ["mappack", "generalsonline", "quickmatch", "competitive"]; + + /// + /// Default tags for GameData patch manifests. + /// + public static readonly string[] GameDataTags = ["patch", "generalsonline"]; } diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index 8e934757c..bedf489a3 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -87,11 +87,37 @@ public static bool IsPathContainedIn(string candidatePath, string containerDirec fullContainer += Path.DirectorySeparatorChar; } - return fullCandidate.StartsWith(fullContainer, PathComparison) || + var isContained = fullCandidate.StartsWith(fullContainer, PathComparison) || string.Equals( fullCandidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), fullContainer.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), PathComparison); + + if (!isContained) + { + return false; + } + + if (File.Exists(fullCandidate)) + { + var fileInfo = new FileInfo(fullCandidate); + var target = fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + return IsPathContainedIn(target.FullName, fullContainer); + } + } + else if (Directory.Exists(fullCandidate)) + { + var dirInfo = new DirectoryInfo(fullCandidate); + var target = dirInfo.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + return IsPathContainedIn(target.FullName, fullContainer); + } + } + + return true; } catch { diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs new file mode 100644 index 000000000..f4ef7d82f --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs @@ -0,0 +1,27 @@ +using GenHub.Core.Models.Manifest; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Defines a precondition or environment check for an installation step. +/// Allows domain-specific probes (e.g. system service or installed anti-cheat detection) +/// to determine whether a step is already satisfied. +/// +public interface IInstallationStepPrecondition +{ + /// + /// Determines whether this precondition can handle the specified installation step. + /// + /// The installation step to inspect. + /// The content manifest declaring the step. + /// if this precondition applies to the step; otherwise, . + bool CanHandle(InstallationStep step, ContentManifest manifest); + + /// + /// Determines whether the step's goal is already fulfilled in the local environment. + /// + /// The installation step to evaluate. + /// The content manifest declaring the step. + /// if the step is already fulfilled; otherwise, . + bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest); +} diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index 196f4b1dc..a2ed56da0 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -208,6 +208,7 @@ public UserSettings Clone() UseInstallationAdjacentStorage = UseInstallationAdjacentStorage, ExplicitlySetProperties = [.. ExplicitlySetProperties], CasConfiguration = (CasConfiguration?)CasConfiguration?.Clone() ?? new CasConfiguration(), + ExecutedInstallationSteps = ExecutedInstallationSteps != null ? [.. ExecutedInstallationSteps] : [], SkippedUpdateVersions = SkippedUpdateVersions != null ? new Dictionary(SkippedUpdateVersions) : [], PreferredUpdateStrategy = PreferredUpdateStrategy, PublisherSubscriptions = PublisherSubscriptions != null diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 4ef6f2ad4..1183b0d18 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -187,7 +187,21 @@ public TestContentProvider( protected override IContentResolver Resolver => _resolver; - protected override IContentDeliverer Deliverer => _deliverer; + public override Task> GetValidatedContentAsync( + string contentId, + CancellationToken cancellationToken = default) + { + var manifest = new ContentManifest + { + Id = ManifestId.Create(contentId), + Name = "Test Content", + Version = "1.0.0", + ContentType = ContentType.Map, + TargetGame = GameType.Generals, + }; + + return Task.FromResult(OperationResult.CreateSuccess(manifest)); + } protected override Task> PrepareContentInternalAsync( ContentManifest manifest, string workingDirectory, IProgress? progress, CancellationToken cancellationToken) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 98362c1f6..9eaced536 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -29,6 +29,9 @@ public sealed class InstallationInstructionsServiceTests : IDisposable private readonly UserSettings _userSettings; private readonly InstallationInstructionsService _service; + /// + /// Initializes a new instance of the class. + /// public InstallationInstructionsServiceTests() { _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-inst-tests-{Guid.NewGuid():N}"); @@ -50,6 +53,9 @@ public InstallationInstructionsServiceTests() NullLogger.Instance); } + /// + /// Cleans up temporary resources after test execution. + /// public void Dispose() { if (Directory.Exists(_tempDirectory)) @@ -65,6 +71,10 @@ public void Dispose() } } + /// + /// Verifies that executing post-install steps succeeds when no steps are declared. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess() { @@ -76,6 +86,10 @@ public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess() Assert.True(result.Success); } + /// + /// Verifies that executing installer steps from an untrusted publisher fails. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_UntrustedPublisher_FailsExecution() { @@ -104,6 +118,10 @@ public async Task ExecutePostInstallStepsAsync_UntrustedPublisher_FailsExecution Assert.Contains("not authorized to execute installation steps", result.FirstError); } + /// + /// Verifies that paths attempting directory traversal are rejected. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecution() { @@ -132,6 +150,10 @@ public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecutio Assert.Contains("escapes the working directory", result.FirstError); } + /// + /// Verifies that installer executables not declared in the manifest files list fail. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution() { @@ -165,6 +187,10 @@ public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution( Assert.Contains("not declared in manifest files", result.FirstError); } + /// + /// Verifies that hash mismatch during installer integrity check fails execution. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution() { @@ -209,6 +235,10 @@ public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution() Assert.Contains("Integrity verification failed", result.FirstError); } + /// + /// Verifies that remove file steps successfully delete the target file. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile() { @@ -236,6 +266,10 @@ public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile() Assert.False(File.Exists(fullPath)); } + /// + /// Verifies that rename file steps successfully move target files. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() { @@ -258,6 +292,7 @@ public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() TargetRelativePath = sourceFile, DestinationRelativePath = destFile, StepKey = "test_rename_step", + RunOnce = true, }, ], }; @@ -271,19 +306,32 @@ public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() Assert.True(_userSettings.IsInstallationStepExecuted("test_rename_step")); } + /// + /// Verifies that verified installer execution runs and dispatches user notifications. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotification() { - var scriptName = OperatingSystem.IsWindows() ? "test_installer.bat" : "test_installer.sh"; + var scriptName = OperatingSystem.IsWindows() ? "test_installer.exe" : "test_installer.sh"; var fullPath = Path.Combine(_tempDirectory, scriptName); - var scriptContent = OperatingSystem.IsWindows() ? "@exit 0" : "#!/bin/sh\nexit 0\n"; - File.WriteAllText(fullPath, scriptContent); - if (!OperatingSystem.IsWindows()) + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } + const string expectedHash = "test_installer_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + var manifest = CreateBaseManifest(); manifest.Publisher = new PublisherInfo { @@ -295,6 +343,7 @@ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotific new ManifestFile { RelativePath = scriptName, + Hash = expectedHash, }, ]; manifest.InstallationInstructions = new InstallationInstructions @@ -306,6 +355,7 @@ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotific Name = GeneralsOnlineConstants.EacStepName, Kind = InstallationStepKind.RunVerifiedInstaller, TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], StatusMessage = GeneralsOnlineConstants.EacStatusMessage, StepKey = GeneralsOnlineConstants.EacStepKey, RunOnce = true, @@ -333,6 +383,10 @@ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotific Times.Once); } + /// + /// Verifies that run-once steps already recorded in user settings are skipped. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsExecution() { @@ -374,19 +428,32 @@ public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsE Times.Never); } + /// + /// Verifies that forcing execution re-runs run-once steps even if recorded in settings. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_ExecutesEvenIfRecorded() { - var scriptName = OperatingSystem.IsWindows() ? "test_force_installer.bat" : "test_force_installer.sh"; + var scriptName = OperatingSystem.IsWindows() ? "test_force_installer.exe" : "test_force_installer.sh"; var fullPath = Path.Combine(_tempDirectory, scriptName); - var scriptContent = OperatingSystem.IsWindows() ? "@exit 0" : "#!/bin/sh\nexit 0\n"; - File.WriteAllText(fullPath, scriptContent); - if (!OperatingSystem.IsWindows()) + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } + const string expectedHash = "test_force_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + var manifest = CreateBaseManifest(); manifest.Publisher = new PublisherInfo { @@ -395,7 +462,11 @@ public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_Executes }; manifest.Files = [ - new ManifestFile { RelativePath = scriptName }, + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, ]; manifest.InstallationInstructions = new InstallationInstructions { @@ -406,6 +477,7 @@ public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_Executes Name = GeneralsOnlineConstants.EacStepName, Kind = InstallationStepKind.RunVerifiedInstaller, TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], StatusMessage = GeneralsOnlineConstants.EacStatusMessage, StepKey = GeneralsOnlineConstants.EacStepKey, RunOnce = true, @@ -429,6 +501,10 @@ public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_Executes Times.Once); } + /// + /// Verifies that unknown installation step kinds return failure. + /// + /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure() { diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs index 3f2672c67..f12d25577 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs @@ -25,6 +25,7 @@ namespace GenHub.Features.Content.Services.CommunityOutpost; /// Available content resolvers. /// Available content deliverers. /// The content validator. +/// The installation instructions service. /// The logger. public class CommunityOutpostProvider( IProviderDefinitionLoader providerDefinitionLoader, diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs index 69b671765..73ac811e1 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs @@ -122,7 +122,8 @@ public async Task> DeliverContentAsync( packageManifest.Publisher?.Name ?? string.Empty, packageManifest.Publisher?.Website ?? string.Empty, packageManifest.Publisher?.SupportUrl ?? string.Empty, - packageManifest.Publisher?.ContactEmail ?? string.Empty) + packageManifest.Publisher?.ContactEmail ?? string.Empty, + packageManifest.Publisher?.PublisherType ?? string.Empty) .WithMetadata( packageManifest.Metadata?.Description ?? string.Empty, packageManifest.Metadata?.Tags, diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs index 149278ca7..e63e225cf 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs @@ -68,7 +68,8 @@ public async Task> DeliverContentAsync( packageManifest.Publisher?.Name ?? string.Empty, packageManifest.Publisher?.Website ?? string.Empty, packageManifest.Publisher?.SupportUrl ?? string.Empty, - packageManifest.Publisher?.ContactEmail ?? string.Empty) + packageManifest.Publisher?.ContactEmail ?? string.Empty, + packageManifest.Publisher?.PublisherType ?? string.Empty) .WithMetadata( packageManifest.Metadata?.Description ?? string.Empty, packageManifest.Metadata?.Tags, @@ -130,13 +131,26 @@ public async Task> DeliverContentAsync( $"Failed to download {file.RelativePath}: {downloadResult.FirstError}"); } - // Add the delivered file using the builder - await deliveredManifest.AddRemoteFileAsync( - file.RelativePath, - file.DownloadUrl ?? string.Empty, - ContentSourceType.ContentAddressable, - isExecutable: file.IsExecutable, - permissions: file.Permissions); + // Add the delivered file using the builder preserving hash + if (!string.IsNullOrEmpty(file.Hash)) + { + var fileInfo = new FileInfo(localPath); + await deliveredManifest.AddContentAddressableFileAsync( + file.RelativePath, + file.Hash, + fileInfo.Exists ? fileInfo.Length : file.Size, + isExecutable: file.IsExecutable, + permissions: file.Permissions); + } + else + { + await deliveredManifest.AddRemoteFileAsync( + file.RelativePath, + file.DownloadUrl ?? string.Empty, + ContentSourceType.ContentAddressable, + isExecutable: file.IsExecutable, + permissions: file.Permissions); + } processedFiles++; } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs new file mode 100644 index 000000000..4ce6cf9d9 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; + +namespace GenHub.Features.Content.Services.GeneralsOnline; + +/// +/// Precondition that checks whether Easy Anti-Cheat EOS service is already installed on Windows. +/// +public class EasyAntiCheatPrecondition : IInstallationStepPrecondition +{ + /// + public bool CanHandle(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows() || step == null) + { + return false; + } + + if (step.Kind != InstallationStepKind.RunVerifiedInstaller) + { + return false; + } + + var fileName = Path.GetFileName(step.TargetRelativePath ?? string.Empty); + return string.Equals(fileName, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase); + } + + /// + public bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + if (!string.IsNullOrEmpty(programFilesX86)) + { + var serviceExe = Path.Combine(programFilesX86, "EasyAntiCheat_EOS", "EasyAntiCheat_EOS.exe"); + if (File.Exists(serviceExe)) + { + return true; + } + } + + var commonProgramFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFilesX86); + if (!string.IsNullOrEmpty(commonProgramFilesX86)) + { + var commonExe = Path.Combine(commonProgramFilesX86, "EasyAntiCheat", "EasyAntiCheat_EOS.exe"); + if (File.Exists(commonExe)) + { + return true; + } + } + } + catch + { + return false; + } + + return false; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index b95322d35..6047d02c5 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -463,20 +463,6 @@ private List CreateVariantManifestsFromOriginal(ContentManifest InstallationInstructions = originalManifest.InstallationInstructions ?? new InstallationInstructions { WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, - PostInstallSteps = - [ - new InstallationStep - { - Name = GeneralsOnlineConstants.EacStepName, - Kind = InstallationStepKind.RunVerifiedInstaller, - TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, - Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], - RequiresElevation = true, - StatusMessage = GeneralsOnlineConstants.EacStatusMessage, - StepKey = GeneralsOnlineConstants.EacStepKey, - RunOnce = true, - }, - ], }, }); @@ -712,7 +698,7 @@ private async Task> UpdateManifestsWithExtractedFiles( if (manifest.ContentType == ContentType.GameClient && filesWithHashes.Any(file => !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)) && - !instructions.PostInstallSteps.Any(s => string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) + instructions.PostInstallSteps.All(s => !string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) { instructions.PostInstallSteps.Add(new InstallationStep { diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 3514bc777..c8acccfec 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -25,13 +25,33 @@ namespace GenHub.Features.Content.Services; /// The file hash provider for integrity verification. /// The notification service for user awareness. /// The user settings service for tracking executed installation steps across updates. +/// Optional installation step preconditions for environment detection. /// The logger instance. public class InstallationInstructionsService( IFileHashProvider hashProvider, INotificationService notificationService, IUserSettingsService? userSettingsService, + IEnumerable? preconditions, ILogger logger) : IInstallationInstructionsService { + private static readonly TimeSpan InstallerStepTimeout = TimeSpan.FromMinutes(10); + + /// + /// Initializes a new instance of the class. + /// + /// The file hash provider for integrity verification. + /// The notification service for user awareness. + /// The user settings service for tracking executed installation steps across updates. + /// The logger instance. + public InstallationInstructionsService( + IFileHashProvider hashProvider, + INotificationService notificationService, + IUserSettingsService? userSettingsService, + ILogger logger) + : this(hashProvider, notificationService, userSettingsService, null, logger) + { + } + /// public async Task ExecutePreInstallStepsAsync( ContentManifest manifest, @@ -137,7 +157,7 @@ private async Task ExecuteSingleStepAsync( { var stepKey = GetStepKey(step, manifest); - if (!force && step.RunOnce && ShouldSkipStep(step, stepKey)) + if (!force && step.RunOnce && await ShouldSkipStepAsync(step, stepKey, manifest, cancellationToken)) { logger.LogInformation( "Skipping installation step '{StepName}' for manifest {ManifestId} because it has already been executed (key: {StepKey})", @@ -155,7 +175,7 @@ private async Task ExecuteSingleStepAsync( return OperationResult.CreateSuccess(); } - OperationResult result; + var result = OperationResult.CreateFailure("Uninitialized step result"); switch (step.Kind) { case InstallationStepKind.RunVerifiedInstaller: @@ -175,7 +195,7 @@ private async Task ExecuteSingleStepAsync( return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); } - if (result.Success && userSettingsService != null && !string.IsNullOrWhiteSpace(stepKey)) + if (result.Success && step.RunOnce && userSettingsService != null && !string.IsNullOrWhiteSpace(stepKey)) { userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); try @@ -191,66 +211,40 @@ private async Task ExecuteSingleStepAsync( return result; } - private bool ShouldSkipStep(InstallationStep step, string stepKey) + private async Task ShouldSkipStepAsync( + InstallationStep step, + string stepKey, + ContentManifest manifest, + CancellationToken cancellationToken) { if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true) { return true; } - if (OperatingSystem.IsWindows() && IsEacAlreadyInstalled(step)) + if (preconditions != null) { - userSettingsService?.Update(s => s.RecordInstallationStepExecuted(stepKey)); - return true; - } - - return false; - } - - private bool IsEacAlreadyInstalled(InstallationStep step) - { - if (!OperatingSystem.IsWindows()) - { - return false; - } - - if (step.Kind != InstallationStepKind.RunVerifiedInstaller) - { - return false; - } - - var fileName = Path.GetFileName(step.TargetRelativePath ?? string.Empty); - if (!string.Equals(fileName, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - try - { - var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - if (!string.IsNullOrEmpty(programFilesX86)) + foreach (var precondition in preconditions) { - var serviceExe = Path.Combine(programFilesX86, "EasyAntiCheat_EOS", "EasyAntiCheat_EOS.exe"); - if (File.Exists(serviceExe)) + if (precondition.CanHandle(step, manifest) && precondition.IsAlreadyFulfilled(step, manifest)) { - return true; - } - } + if (userSettingsService != null && !string.IsNullOrWhiteSpace(stepKey)) + { + userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); + try + { + await userSettingsService.SaveAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist detected installation step key '{StepKey}'", stepKey); + } + } - var commonProgramFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFilesX86); - if (!string.IsNullOrEmpty(commonProgramFilesX86)) - { - var commonExe = Path.Combine(commonProgramFilesX86, "EasyAntiCheat", "EasyAntiCheat_EOS.exe"); - if (File.Exists(commonExe)) - { return true; } } } - catch - { - return false; - } return false; } @@ -331,24 +325,28 @@ private async Task ExecuteRunVerifiedInstallerAsync( return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' is not declared in manifest files."); } - if (!string.IsNullOrWhiteSpace(manifestFile.Hash)) + if (string.IsNullOrWhiteSpace(manifestFile.Hash)) { - var computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); - if (!string.Equals(computedHash, manifestFile.Hash, StringComparison.OrdinalIgnoreCase)) - { - logger.LogError( - "Integrity verification failed for installer '{Target}'. Expected: {Expected}, Computed: {Computed}", - step.TargetRelativePath, - manifestFile.Hash, - computedHash); + logger.LogError("Installer '{Target}' has no declared hash in manifest {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure( + $"Installer '{step.TargetRelativePath}' has no declared hash and cannot be verified."); + } - return OperationResult.CreateFailure( - $"Integrity verification failed for installer '{step.TargetRelativePath}'."); - } + var computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); + if (!string.Equals(computedHash, manifestFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + logger.LogError( + "Integrity verification failed for installer '{Target}'. Expected: {Expected}, Computed: {Computed}", + step.TargetRelativePath, + manifestFile.Hash, + computedHash); - logger.LogDebug("Integrity verified for installer '{Target}'", step.TargetRelativePath); + return OperationResult.CreateFailure( + $"Integrity verification failed for installer '{step.TargetRelativePath}'."); } + logger.LogDebug("Integrity verified for installer '{Target}'", step.TargetRelativePath); + // 4. User notification var displayTitle = !string.IsNullOrWhiteSpace(step.Name) ? step.Name : "Running Installation Step"; var displayMessage = !string.IsNullOrWhiteSpace(step.StatusMessage) @@ -409,7 +407,19 @@ private async Task ExecuteRunVerifiedInstallerAsync( return OperationResult.CreateFailure($"Failed to start installer '{step.TargetRelativePath}'."); } - await process.WaitForExitAsync(cancellationToken); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(InstallerStepTimeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError("Installer step '{StepName}' timed out", step.Name); + notificationService.ShowError("Installation Step Failed", $"Step '{step.Name}' timed out."); + return OperationResult.CreateFailure($"Installation step '{step.Name}' timed out."); + } if (process.ExitCode != 0) { diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index 72c870610..ddf16ae44 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -240,6 +240,9 @@ private static void AddGeneralsOnlinePipeline(IServiceCollection services) services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(sp => sp.GetRequiredService()); + + // Register Easy Anti-Cheat installation step precondition + services.AddSingleton(); } /// From 999fd6979efd49cd760f0f03a1d10ad932e330a9 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:40:52 +0000 Subject: [PATCH 06/26] fix(content): restore TestContentProvider Deliverer and reduce UpdateManifestsWithExtractedFiles complexity --- .../Content/BaseContentProviderTests.cs | 2 + .../GeneralsOnlineManifestFactory.cs | 293 +++++++++--------- 2 files changed, 153 insertions(+), 142 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 1183b0d18..8741d7ac4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -187,6 +187,8 @@ public TestContentProvider( protected override IContentResolver Resolver => _resolver; + protected override IContentDeliverer Deliverer => _deliverer; + public override Task> GetValidatedContentAsync( string contentId, CancellationToken cancellationToken = default) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 6047d02c5..23cff92c5 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -537,6 +537,13 @@ private List CreateVariantManifestsFromOriginal(ContentManifest /// The path to the directory containing extracted files. /// Token to cancel the operation if needed. /// Updated content manifests with file hashes and details. + private readonly record struct ExtractedFileInfo( + string RelativePath, + FileInfo FileInfo, + string Hash, + bool IsMap, + bool IsGameData); + private async Task> UpdateManifestsWithExtractedFiles( List manifests, string extractPath, @@ -546,10 +553,63 @@ private async Task> UpdateManifestsWithExtractedFiles( cancellationToken.ThrowIfCancellationRequested(); + var filesWithHashes = await ScanExtractedFilesAsync(extractPath, cancellationToken); + var updatedManifests = new List(); + + foreach (var manifest in manifests) + { + var manifestFiles = BuildManifestFilesForManifest(manifest, filesWithHashes); + + if (manifestFiles.Count == 0) + { + if (manifest.ContentType is ContentType.MapPack or ContentType.Patch) + { + logger.LogInformation( + "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path", + manifest.ContentType, + manifest.Name); + continue; + } + + logger.LogError( + "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'", + manifest.Name, + manifest.ContentType, + extractPath); + throw new InvalidDataException( + $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'."); + } + + var instructions = BuildInstallationInstructions(manifest, filesWithHashes); + + updatedManifests.Add(new ContentManifest + { + Id = manifest.Id, + Name = manifest.Name, + Version = manifest.Version, + ContentType = manifest.ContentType, + TargetGame = manifest.TargetGame, + Publisher = manifest.Publisher, + Metadata = manifest.Metadata, + Files = manifestFiles, + Dependencies = manifest.Dependencies, + InstallationInstructions = instructions, + }); + } + + ReconcileMissingMapPackDependencies(updatedManifests); + + return updatedManifests; + } + + private async Task> ScanExtractedFilesAsync( + string extractPath, + CancellationToken cancellationToken) + { var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories); logger.LogInformation("Processing {Count} files", allFiles.Length); - List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap, bool IsGameData)> filesWithHashes = []; + var filesWithHashes = new List(allFiles.Length); foreach (var filePath in allFiles) { @@ -558,192 +618,141 @@ private async Task> UpdateManifestsWithExtractedFiles( var relativePath = Path.GetRelativePath(extractPath, filePath); var fileInfo = new FileInfo(filePath); - // Determine if this file is inside the Maps directory var isMap = relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + "/", StringComparison.OrdinalIgnoreCase); - // Determine if this file is inside the GeneralsOnlineGameData directory var isGameData = relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + "/", StringComparison.OrdinalIgnoreCase); - var hash = string.Empty; + string hash; using (var stream = File.OpenRead(filePath)) { var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken); hash = Convert.ToHexString(hashBytes).ToLowerInvariant(); } - filesWithHashes.Add((relativePath, fileInfo, hash, isMap, isGameData)); + filesWithHashes.Add(new ExtractedFileInfo(relativePath, fileInfo, hash, isMap, isGameData)); logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap}, isGameData: {IsGameData})", relativePath, fileInfo.Length, hash[..8], isMap, isGameData); } - List updatedManifests = []; + return filesWithHashes; + } - foreach (var manifest in manifests) - { - List manifestFiles = []; - var isMapPackManifest = manifest.ContentType == ContentType.MapPack; - var isPatchManifest = manifest.ContentType == ContentType.Patch; + private List BuildManifestFilesForManifest( + ContentManifest manifest, + List filesWithHashes) + { + var manifestFiles = new List(); - if (isMapPackManifest) + if (manifest.ContentType == ContentType.MapPack) + { + foreach (var file in filesWithHashes) { - // MapPack manifest: only include map files with UserMapsDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) + if (file.IsMap) { - if (!isMap) - { - continue; - } - - manifestFiles.Add(CreateMapManifestFile(relativePath, fileInfo, hash)); + manifestFiles.Add(CreateMapManifestFile(file.RelativePath, file.FileInfo, file.Hash)); } - - logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count); } - else if (isPatchManifest) + + logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count); + } + else if (manifest.ContentType == ContentType.Patch) + { + foreach (var file in filesWithHashes) { - // Data patch manifest: only include GeneralsOnlineGameData files with UserDataDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) + if (file.IsGameData) { - if (!isGameData) - { - continue; - } - - manifestFiles.Add(CreateGameDataManifestFile(relativePath, fileInfo, hash)); + manifestFiles.Add(CreateGameDataManifestFile(file.RelativePath, file.FileInfo, file.Hash)); } - - logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); } - else - { - // Game client manifest: include executables and shared files (skipping maps and game data files) - // Since 060526_QFE1 the portable ships an Easy Anti-Cheat bootstrapper that starts the - // binary named by EasyAntiCheat/Settings.json. When present it is the only launch target; - // the wrapped binary stays in the workspace as ordinary content for EAC to start. - var hasEacLauncher = filesWithHashes.Any(file => - !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable)); - - var targetExecutable = hasEacLauncher - ? GameClientConstants.GeneralsOnlineEacLauncherExecutable - : GameClientConstants.GeneralsOnline60HzExecutable; - - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) - { - var isExecutable = false; - - // Skip map files and game data files in GameClient manifests - if (isMap || isGameData) - { - continue; - } - if (IsArchiveRootFile(relativePath, targetExecutable)) - { - isExecutable = true; - } - - manifestFiles.Add(new ManifestFile - { - RelativePath = relativePath, - Size = fileInfo.Length, - Hash = hash, - SourceType = ContentSourceType.ContentAddressable, - SourcePath = fileInfo.FullName, - InstallTarget = ContentInstallTarget.Workspace, - IsExecutable = isExecutable, - }); - } + logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); + } + else + { + var hasEacLauncher = filesWithHashes.Any(file => + !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable)); - logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); - } + var targetExecutable = hasEacLauncher + ? GameClientConstants.GeneralsOnlineEacLauncherExecutable + : GameClientConstants.GeneralsOnline60HzExecutable; - if (manifestFiles.Count == 0) + foreach (var file in filesWithHashes) { - if (isMapPackManifest || isPatchManifest) + if (file.IsMap || file.IsGameData) { - logger.LogInformation( - "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path", - manifest.ContentType, - manifest.Name); continue; } - if (manifest.ContentType == ContentType.GameClient) - { - logger.LogError( - "GameClient manifest '{Name}' has zero files in extract path '{ExtractPath}'", - manifest.Name, - extractPath); - throw new InvalidDataException( - $"GameClient manifest '{manifest.Name}' has no files in extract path '{extractPath}'."); - } + var isExecutable = IsArchiveRootFile(file.RelativePath, targetExecutable); - logger.LogError( - "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'", - manifest.Name, - manifest.ContentType, - extractPath); - throw new InvalidDataException( - $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'."); - } - - var instructions = new InstallationInstructions - { - WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, - DownloadHash = manifest.InstallationInstructions?.DownloadHash, - PreInstallSteps = [.. manifest.InstallationInstructions?.PreInstallSteps ?? []], - PostInstallSteps = [.. manifest.InstallationInstructions?.PostInstallSteps ?? []], - }; - - if (manifest.ContentType == ContentType.GameClient && - filesWithHashes.Any(file => !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)) && - instructions.PostInstallSteps.All(s => !string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) - { - instructions.PostInstallSteps.Add(new InstallationStep + manifestFiles.Add(new ManifestFile { - Name = GeneralsOnlineConstants.EacStepName, - Kind = InstallationStepKind.RunVerifiedInstaller, - TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, - Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], - RequiresElevation = true, - StatusMessage = GeneralsOnlineConstants.EacStatusMessage, - StepKey = GeneralsOnlineConstants.EacStepKey, - RunOnce = true, + RelativePath = file.RelativePath, + Size = file.FileInfo.Length, + Hash = file.Hash, + SourceType = ContentSourceType.ContentAddressable, + SourcePath = file.FileInfo.FullName, + InstallTarget = ContentInstallTarget.Workspace, + IsExecutable = isExecutable, }); } - updatedManifests.Add(new ContentManifest + logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); + } + + return manifestFiles; + } + + private static InstallationInstructions BuildInstallationInstructions( + ContentManifest manifest, + List filesWithHashes) + { + var instructions = new InstallationInstructions + { + WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = manifest.InstallationInstructions?.DownloadHash, + PreInstallSteps = [.. manifest.InstallationInstructions?.PreInstallSteps ?? []], + PostInstallSteps = [.. manifest.InstallationInstructions?.PostInstallSteps ?? []], + }; + + if (manifest.ContentType == ContentType.GameClient && + filesWithHashes.Any(file => !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)) && + instructions.PostInstallSteps.All(s => !string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) + { + instructions.PostInstallSteps.Add(new InstallationStep { - Id = manifest.Id, - Name = manifest.Name, - Version = manifest.Version, - ContentType = manifest.ContentType, - TargetGame = manifest.TargetGame, - Publisher = manifest.Publisher, - Metadata = manifest.Metadata, - Files = manifestFiles, - Dependencies = manifest.Dependencies, - InstallationInstructions = instructions, + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, }); } - // If MapPack was not created from archive, remove MapPack dependency so dependency resolution does not fail - var hasMapPack = updatedManifests.Any(m => m.ContentType == ContentType.MapPack); - if (!hasMapPack) + return instructions; + } + + private void ReconcileMissingMapPackDependencies(List manifests) + { + var hasMapPack = manifests.Any(m => m.ContentType == ContentType.MapPack); + if (hasMapPack) + { + return; + } + + foreach (var m in manifests) { - foreach (var m in updatedManifests) + if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack)) { - if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack)) - { - logger.LogWarning( - "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive", - m.Name); - m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList(); - } + logger.LogWarning( + "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive", + m.Name); + m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList(); } } - - return updatedManifests; } } From ca031466a122d4894e08bf7db8d526b9e3d67075 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:47:04 +0000 Subject: [PATCH 07/26] fix(tests): resolve type ambiguities, constructor compatibility, and test assertions --- .../IInstallationInstructionsService.cs | 32 ++++++++++++++- .../Content/BaseContentProviderTests.cs | 1 + .../InstallationInstructionsServiceTests.cs | 1 + .../Manifest/ContentManifestBuilderTests.cs | 4 +- .../ContentProviders/BaseContentProvider.cs | 39 +++++++++++++------ .../InstallationInstructionsService.cs | 24 +++++++++++- .../Publishers/SuperHackersProvider.cs | 22 ++++++++++- 7 files changed, 104 insertions(+), 19 deletions(-) diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs index ba3e77377..daacde5b6 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -18,14 +18,28 @@ public interface IInstallationInstructionsService /// The content manifest declaring pre-installation steps. /// The working directory containing the content files. /// Optional progress reporter for acquisition status. + /// A token to cancel the operation. + /// A result indicating whether all pre-installation steps succeeded. + Task ExecutePreInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + /// + /// Executes pre-installation steps for the specified manifest, optionally forcing run-once steps. + /// + /// The content manifest declaring pre-installation steps. + /// The working directory containing the content files. /// Whether to force execution of steps marked as run-once even if already executed. + /// Optional progress reporter for acquisition status. /// A token to cancel the operation. /// A result indicating whether all pre-installation steps succeeded. Task ExecutePreInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force, IProgress? progress = null, - bool force = false, CancellationToken cancellationToken = default); /// @@ -34,13 +48,27 @@ Task ExecutePreInstallStepsAsync( /// The content manifest declaring post-installation steps. /// The working directory containing the content files. /// Optional progress reporter for acquisition status. + /// A token to cancel the operation. + /// A result indicating whether all post-installation steps succeeded. + Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + /// + /// Executes post-installation steps for the specified manifest, optionally forcing run-once steps. + /// + /// The content manifest declaring post-installation steps. + /// The working directory containing the content files. /// Whether to force execution of steps marked as run-once even if already executed. + /// Optional progress reporter for acquisition status. /// A token to cancel the operation. /// A result indicating whether all post-installation steps succeeded. Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force, IProgress? progress = null, - bool force = false, CancellationToken cancellationToken = default); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 8741d7ac4..6320fc23e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 9eaced536..8b5016693 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs index 6a9702565..51757b2b4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs @@ -216,7 +216,7 @@ public void WithInstallationInstructions_SetsCompleteObject() { var instructions = new InstallationInstructions { - WorkspaceStrategy = WorkspaceStrategy.IsolatedDirectory, + WorkspaceStrategy = WorkspaceStrategy.FullCopy, DownloadHash = "abc123hash", PostInstallSteps = [ @@ -235,7 +235,7 @@ public void WithInstallationInstructions_SetsCompleteObject() .Build(); Assert.NotNull(result.InstallationInstructions); - Assert.Equal(WorkspaceStrategy.IsolatedDirectory, result.InstallationInstructions.WorkspaceStrategy); + Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy); Assert.Equal("abc123hash", result.InstallationInstructions.DownloadHash); Assert.Single(result.InstallationInstructions.PostInstallSteps); Assert.Equal("Step 1", result.InstallationInstructions.PostInstallSteps[0].Name); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index a28547050..2324699bd 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -20,10 +20,22 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// public abstract class BaseContentProvider( IContentValidator contentValidator, - IInstallationInstructionsService installationInstructionsService, + IInstallationInstructionsService? installationInstructionsService, ILogger logger ) : IContentProvider { + /// + /// Initializes a new instance of the class without an installation instructions service. + /// + /// The content validator. + /// The logger. + public BaseContentProvider( + IContentValidator contentValidator, + ILogger logger) + : this(contentValidator, null, logger) + { + } + /// public abstract string SourceName { get; } @@ -144,17 +156,20 @@ public virtual async Task> PrepareContentAsync( if (result.Success && result.Data != null) { - // Execute post-installation steps if declared on the delivered manifest - var stepExecutionResult = await installationInstructionsService.ExecutePostInstallStepsAsync( - result.Data, - workingDirectory, - progress: progress, - cancellationToken: cancellationToken); - - if (!stepExecutionResult.Success) + if (installationInstructionsService != null) { - Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); - return OperationResult.CreateFailure(stepExecutionResult.Errors); + // Execute post-installation steps if declared on the delivered manifest + var stepExecutionResult = await installationInstructionsService.ExecutePostInstallStepsAsync( + result.Data, + workingDirectory, + progress: progress, + cancellationToken: cancellationToken); + + if (!stepExecutionResult.Success) + { + Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); + return OperationResult.CreateFailure(stepExecutionResult.Errors); + } } // Final validation of prepared content @@ -221,7 +236,7 @@ public virtual async Task> PrepareContentAsync( /// /// Gets the installation instructions service for post-install execution. /// - protected IInstallationInstructionsService InstallationInstructionsService => installationInstructionsService; + protected IInstallationInstructionsService? InstallationInstructionsService => installationInstructionsService; /// /// Gets the discoverer for this provider. diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index c8acccfec..4f5d52596 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -52,12 +52,22 @@ public InstallationInstructionsService( { } + /// + public Task ExecutePreInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + return ExecutePreInstallStepsAsync(manifest, workingDirectory, force: false, progress, cancellationToken); + } + /// public async Task ExecutePreInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force, IProgress? progress = null, - bool force = false, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(manifest); @@ -83,12 +93,22 @@ public async Task ExecutePreInstallStepsAsync( cancellationToken); } + /// + public Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + return ExecutePostInstallStepsAsync(manifest, workingDirectory, force: false, progress, cancellationToken); + } + /// public async Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + bool force, IProgress? progress = null, - bool force = false, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(manifest); diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index e07282293..6210e1d7f 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -28,10 +28,30 @@ public class SuperHackersProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, - IInstallationInstructionsService installationInstructionsService, + IInstallationInstructionsService? installationInstructionsService, ILogger logger) : BaseContentProvider(contentValidator, installationInstructionsService, logger) { + /// + /// Initializes a new instance of the class without an installation instructions service. + /// + /// The provider definition loader. + /// The GitHub API client. + /// The content resolvers. + /// The content deliverers. + /// The content validator. + /// The logger. + public SuperHackersProvider( + IProviderDefinitionLoader providerDefinitionLoader, + IGitHubApiClient gitHubApiClient, + IEnumerable resolvers, + IEnumerable deliverers, + IContentValidator contentValidator, + ILogger logger) + : this(providerDefinitionLoader, gitHubApiClient, resolvers, deliverers, contentValidator, null, logger) + { + } + private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(SuperHackersConstants.ResolverId, StringComparison.OrdinalIgnoreCase) == true) ?? throw new InvalidOperationException("No GitHub resolver found for SuperHackers"); From 86f6c618898a321734bcce15c32adf22a0368a8b Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:52:13 +0000 Subject: [PATCH 08/26] fix(core): resolve StyleCop warnings, doc comments, and ContentType disambiguation --- .../Content/BaseContentProviderTests.cs | 1 + .../InstallationInstructionsServiceTests.cs | 1 + .../GeneralsOnlineManifestFactory.cs | 24 ++++++++++++------- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 6320fc23e..77e8c00f5 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using Moq; using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 8b5016693..6e8da7abb 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -423,6 +423,7 @@ public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsE var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); Assert.True(result.Success); + // Notification should NOT be shown for skipped step _notificationServiceMock.Verify( n => n.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 23cff92c5..39b962ae1 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -526,6 +526,21 @@ private List CreateVariantManifestsFromOriginal(ContentManifest return manifests; } + /// + /// File info extracted from archive for manifest generation. + /// + /// The relative path within archive. + /// The file info. + /// The SHA-256 hash. + /// Whether this is a map file. + /// Whether this is a game data file. + private readonly record struct ExtractedFileInfo( + string RelativePath, + FileInfo FileInfo, + string Hash, + bool IsMap, + bool IsGameData); + /// /// Updates manifests (60Hz, QuickMatch MapPack, and GeneralsOnlineGameData data patch) with extracted file information. /// Computes SHA-256 hashes for all files for CAS integration. @@ -537,13 +552,6 @@ private List CreateVariantManifestsFromOriginal(ContentManifest /// The path to the directory containing extracted files. /// Token to cancel the operation if needed. /// Updated content manifests with file hashes and details. - private readonly record struct ExtractedFileInfo( - string RelativePath, - FileInfo FileInfo, - string Hash, - bool IsMap, - bool IsGameData); - private async Task> UpdateManifestsWithExtractedFiles( List manifests, string extractPath, @@ -704,7 +712,7 @@ private List BuildManifestFilesForManifest( return manifestFiles; } - private static InstallationInstructions BuildInstallationInstructions( + private InstallationInstructions BuildInstallationInstructions( ContentManifest manifest, List filesWithHashes) { From 88d657cb23d144b74a755616522dd0ff8abbf15b Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:58:07 +0000 Subject: [PATCH 09/26] fix(quality): address DeepSource findings for constructor visibility and variable initialization --- .../Content/Services/ContentProviders/BaseContentProvider.cs | 2 +- .../Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 2324699bd..2f612ffde 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -29,7 +29,7 @@ ILogger logger /// /// The content validator. /// The logger. - public BaseContentProvider( + protected BaseContentProvider( IContentValidator contentValidator, ILogger logger) : this(contentValidator, null, logger) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 39b962ae1..80921d65b 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -632,7 +632,7 @@ private async Task> ScanExtractedFilesAsync( var isGameData = relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + "/", StringComparison.OrdinalIgnoreCase); - string hash; + var hash = string.Empty; using (var stream = File.OpenRead(filePath)) { var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken); From 58c51dfdffa6b396e7b00994695c9a0bd4c84664 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 14:28:04 +0000 Subject: [PATCH 10/26] fix(eac): check specific EAC EOS product ID registry key instead of generic service binary --- .../EasyAntiCheatPrecondition.cs | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs index 4ce6cf9d9..17efe65d6 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -1,14 +1,16 @@ using System; using System.IO; +using System.Runtime.Versioning; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; +using Microsoft.Win32; namespace GenHub.Features.Content.Services.GeneralsOnline; /// -/// Precondition that checks whether Easy Anti-Cheat EOS service is already installed on Windows. +/// Precondition that checks whether Easy Anti-Cheat EOS product ID is already registered in the Windows registry. /// public class EasyAntiCheatPrecondition : IInstallationStepPrecondition { @@ -37,26 +39,33 @@ public bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest) return false; } + return IsProductRegisteredOnWindows(step); + } + + [SupportedOSPlatform("windows")] + private static bool IsProductRegisteredOnWindows(InstallationStep step) + { try { - var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - if (!string.IsNullOrEmpty(programFilesX86)) + var productId = step.Arguments is { Count: > 1 } + ? step.Arguments[1] + : GeneralsOnlineConstants.EacProductId; + + if (string.IsNullOrWhiteSpace(productId)) + { + return false; + } + + using var key32 = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\WOW6432Node\EasyAntiCheat_EOS\{productId}"); + if (key32 != null) { - var serviceExe = Path.Combine(programFilesX86, "EasyAntiCheat_EOS", "EasyAntiCheat_EOS.exe"); - if (File.Exists(serviceExe)) - { - return true; - } + return true; } - var commonProgramFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFilesX86); - if (!string.IsNullOrEmpty(commonProgramFilesX86)) + using var key64 = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\EasyAntiCheat_EOS\{productId}"); + if (key64 != null) { - var commonExe = Path.Combine(commonProgramFilesX86, "EasyAntiCheat", "EasyAntiCheat_EOS.exe"); - if (File.Exists(commonExe)) - { - return true; - } + return true; } } catch From 0df2e88a3d37bd10e924068108160016d63f5061 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 14:35:24 +0000 Subject: [PATCH 11/26] fix(installer): kill process tree on timeout and drop inherited EAC steps when executable absent --- .../GeneralsOnlineManifestFactory.cs | 14 ++++++++++++-- .../Services/InstallationInstructionsService.cs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 80921d65b..5fd783242 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -716,16 +716,26 @@ private InstallationInstructions BuildInstallationInstructions( ContentManifest manifest, List filesWithHashes) { + var hasEacSetup = filesWithHashes.Any(file => + !file.IsMap && !file.IsGameData && + IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)); + + var inheritedPostSteps = (manifest.InstallationInstructions?.PostInstallSteps ?? []) + .Where(s => hasEacSetup || !string.Equals( + s.TargetRelativePath, + GameClientConstants.GeneralsOnlineEacSetupExecutable, + StringComparison.OrdinalIgnoreCase)); + var instructions = new InstallationInstructions { WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, DownloadHash = manifest.InstallationInstructions?.DownloadHash, PreInstallSteps = [.. manifest.InstallationInstructions?.PreInstallSteps ?? []], - PostInstallSteps = [.. manifest.InstallationInstructions?.PostInstallSteps ?? []], + PostInstallSteps = [.. inheritedPostSteps], }; if (manifest.ContentType == ContentType.GameClient && - filesWithHashes.Any(file => !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)) && + hasEacSetup && instructions.PostInstallSteps.All(s => !string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) { instructions.PostInstallSteps.Add(new InstallationStep diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 4f5d52596..15f892671 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -437,6 +437,18 @@ private async Task ExecuteRunVerifiedInstallerAsync( catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { logger.LogError("Installer step '{StepName}' timed out", step.Name); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception killEx) + { + logger.LogWarning(killEx, "Failed to terminate timed-out installer step '{StepName}'", step.Name); + } + notificationService.ShowError("Installation Step Failed", $"Step '{step.Name}' timed out."); return OperationResult.CreateFailure($"Installation step '{step.Name}' timed out."); } From bb3d9fe8afa48118516190da429cbf825eccad95 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 14:40:53 +0000 Subject: [PATCH 12/26] refactor(installer): decompose ExecuteRunVerifiedInstallerAsync to reduce cyclomatic complexity --- .../InstallationInstructionsService.cs | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 15f892671..f6acbff8e 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -291,7 +291,37 @@ private async Task ExecuteRunVerifiedInstallerAsync( IProgress? progress, CancellationToken cancellationToken) { - // 1. Publisher authorization check + var authResult = ValidatePublisherAuthorization(manifest, step); + if (!authResult.Success) + { + return authResult; + } + + var pathResult = ValidateInstallerTargetPath(step, workingDirectory, out var targetFullPath); + if (!pathResult.Success) + { + return pathResult; + } + + var integrityResult = await VerifyInstallerIntegrityAsync(step, manifest, targetFullPath, cancellationToken); + if (!integrityResult.Success) + { + return integrityResult; + } + + NotifyStepStarting(step, progress); + + logger.LogInformation( + "Executing verified installer '{Target}' (Elevation: {RequiresElevation}) for manifest {ManifestId}", + step.TargetRelativePath, + step.RequiresElevation, + manifest.Id); + + return await RunInstallerProcessAsync(step, targetFullPath, workingDirectory, cancellationToken); + } + + private OperationResult ValidatePublisherAuthorization(ContentManifest manifest, InstallationStep step) + { var publisherType = manifest.Publisher?.PublisherType ?? string.Empty; var publisherName = manifest.Publisher?.Name ?? string.Empty; @@ -311,14 +341,20 @@ private async Task ExecuteRunVerifiedInstallerAsync( $"Publisher '{(!string.IsNullOrEmpty(publisherType) ? publisherType : publisherName)}' is not authorized to execute installation steps."); } - // 2. Target path validation + return OperationResult.CreateSuccess(); + } + + private OperationResult ValidateInstallerTargetPath(InstallationStep step, string workingDirectory, out string targetFullPath) + { + targetFullPath = string.Empty; + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) { return OperationResult.CreateFailure($"Target relative path is required for executable step '{step.Name}'."); } var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); - var targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); if (!PathHelper.IsPathContainedIn(targetFullPath, workingDirectory)) { @@ -332,7 +368,16 @@ private async Task ExecuteRunVerifiedInstallerAsync( return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' was not found in delivered content."); } - // 3. Manifest file declaration and integrity verification + return OperationResult.CreateSuccess(); + } + + private async Task VerifyInstallerIntegrityAsync( + InstallationStep step, + ContentManifest manifest, + string targetFullPath, + CancellationToken cancellationToken) + { + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath ?? string.Empty); var manifestFile = manifest.Files?.FirstOrDefault(f => string.Equals( PathHelper.NormalizeRelativePath(f.RelativePath), @@ -366,8 +411,11 @@ private async Task ExecuteRunVerifiedInstallerAsync( } logger.LogDebug("Integrity verified for installer '{Target}'", step.TargetRelativePath); + return OperationResult.CreateSuccess(); + } - // 4. User notification + private void NotifyStepStarting(InstallationStep step, IProgress? progress) + { var displayTitle = !string.IsNullOrWhiteSpace(step.Name) ? step.Name : "Running Installation Step"; var displayMessage = !string.IsNullOrWhiteSpace(step.StatusMessage) ? step.StatusMessage @@ -384,14 +432,14 @@ private async Task ExecuteRunVerifiedInstallerAsync( CurrentOperation = displayMessage, CurrentFile = step.TargetRelativePath ?? string.Empty, }); + } - // 5. Process execution - logger.LogInformation( - "Executing verified installer '{Target}' (Elevation: {RequiresElevation}) for manifest {ManifestId}", - step.TargetRelativePath, - step.RequiresElevation, - manifest.Id); - + private async Task RunInstallerProcessAsync( + InstallationStep step, + string targetFullPath, + string workingDirectory, + CancellationToken cancellationToken) + { var startInfo = new ProcessStartInfo { FileName = targetFullPath, From 3df4779fdaff9cf1cb3596f3cd6d7cfc68fa3937 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 16:00:35 +0000 Subject: [PATCH 13/26] fix(installer): address review feedback for authorization, containment, cancellation, and rollback Bind step authorization to caller provider source, port PR #385 path containment checks, handle non-Windows elevation gracefully via OperationResult, terminate process tree on cancellation, add manifest rollback on post-install failure, and remove unused PreInstallSteps. --- GenHub/GenHub.Core/Helpers/PathHelper.cs | 60 ++----- .../IInstallationInstructionsService.cs | 34 +--- .../Manifest/IContentManifestBuilder.cs | 31 ---- .../Manifest/InstallationInstructions.cs | 5 - .../Content/BaseContentProviderTests.cs | 65 ++++++- .../Content/GitHubContentProviderTests.cs | 1 + .../InstallationInstructionsServiceTests.cs | 167 ++++++++++++++++-- .../Manifest/ContentManifestBuilderTests.cs | 18 -- .../ContentProviders/BaseContentProvider.cs | 54 ++++-- .../GeneralsOnlineManifestFactory.cs | 1 - .../GeneralsOnline/GeneralsOnlineProvider.cs | 39 ++++ .../InstallationInstructionsService.cs | 117 ++++++------ .../Manifest/ContentManifestBuilder.cs | 40 +---- 13 files changed, 373 insertions(+), 259 deletions(-) diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index bedf489a3..82e60c330 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -64,60 +64,30 @@ public static string GetSafeParentDirectory(string path) } /// - /// Determines whether the candidate path is contained within the specified container directory. - /// Prevents directory traversal and sibling prefix false positives. + /// Determines whether a candidate path resolves to a location inside a base directory. + /// Both paths are fully normalized first, so .. segments, redundant separators and + /// rooted candidates cannot escape the base directory. Because normalization is textual and a + /// symbolic link or junction redirects a path that reads as contained, both sides are also + /// compared after their links are followed; a path that cannot be resolved — because it does + /// not exist yet, or the filesystem refuses the query — is compared as written. /// - /// The candidate file or directory path to check. - /// The directory that must contain the candidate path. - /// if candidatePath resolves inside containerDirectory; otherwise, . - public static bool IsPathContainedIn(string candidatePath, string containerDirectory) + /// The directory that must contain the candidate path. + /// The path to test for containment. + /// when the candidate resolves inside the base directory; otherwise, . + public static bool IsPathWithinDirectory(string baseDirectory, string candidatePath) { - if (string.IsNullOrWhiteSpace(candidatePath) || string.IsNullOrWhiteSpace(containerDirectory)) + if (string.IsNullOrWhiteSpace(baseDirectory) || string.IsNullOrWhiteSpace(candidatePath)) { return false; } try { - var fullCandidate = Path.GetFullPath(candidatePath); - var fullContainer = Path.GetFullPath(containerDirectory); - - if (!fullContainer.EndsWith(Path.DirectorySeparatorChar) && !fullContainer.EndsWith(Path.AltDirectorySeparatorChar)) - { - fullContainer += Path.DirectorySeparatorChar; - } - - var isContained = fullCandidate.StartsWith(fullContainer, PathComparison) || - string.Equals( - fullCandidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - fullContainer.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - PathComparison); - - if (!isContained) - { - return false; - } - - if (File.Exists(fullCandidate)) - { - var fileInfo = new FileInfo(fullCandidate); - var target = fileInfo.ResolveLinkTarget(returnFinalTarget: true); - if (target != null) - { - return IsPathContainedIn(target.FullName, fullContainer); - } - } - else if (Directory.Exists(fullCandidate)) - { - var dirInfo = new DirectoryInfo(fullCandidate); - var target = dirInfo.ResolveLinkTarget(returnFinalTarget: true); - if (target != null) - { - return IsPathContainedIn(target.FullName, fullContainer); - } - } + var normalizedRoot = Path.GetFullPath(baseDirectory); + var normalizedTarget = Path.GetFullPath(candidatePath); - return true; + return IsContained(normalizedRoot, normalizedTarget) && + IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); } catch { diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs index daacde5b6..6a64a3d64 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -12,47 +12,19 @@ namespace GenHub.Core.Interfaces.Content; /// public interface IInstallationInstructionsService { - /// - /// Executes pre-installation steps for the specified manifest. - /// - /// The content manifest declaring pre-installation steps. - /// The working directory containing the content files. - /// Optional progress reporter for acquisition status. - /// A token to cancel the operation. - /// A result indicating whether all pre-installation steps succeeded. - Task ExecutePreInstallStepsAsync( - ContentManifest manifest, - string workingDirectory, - IProgress? progress = null, - CancellationToken cancellationToken = default); - - /// - /// Executes pre-installation steps for the specified manifest, optionally forcing run-once steps. - /// - /// The content manifest declaring pre-installation steps. - /// The working directory containing the content files. - /// Whether to force execution of steps marked as run-once even if already executed. - /// Optional progress reporter for acquisition status. - /// A token to cancel the operation. - /// A result indicating whether all pre-installation steps succeeded. - Task ExecutePreInstallStepsAsync( - ContentManifest manifest, - string workingDirectory, - bool force, - IProgress? progress = null, - CancellationToken cancellationToken = default); - /// /// Executes post-installation steps for the specified manifest. /// /// The content manifest declaring post-installation steps. /// The working directory containing the content files. + /// The provider source name supplying the content, used for step authorization. /// Optional progress reporter for acquisition status. /// A token to cancel the operation. /// A result indicating whether all post-installation steps succeeded. Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + string? providerSource = null, IProgress? progress = null, CancellationToken cancellationToken = default); @@ -61,6 +33,7 @@ Task ExecutePostInstallStepsAsync( /// /// The content manifest declaring post-installation steps. /// The working directory containing the content files. + /// The provider source name supplying the content, used for step authorization. /// Whether to force execution of steps marked as run-once even if already executed. /// Optional progress reporter for acquisition status. /// A token to cancel the operation. @@ -68,6 +41,7 @@ Task ExecutePostInstallStepsAsync( Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + string? providerSource, bool force, IProgress? progress = null, CancellationToken cancellationToken = default); diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs index 1f833d4d8..e69289608 100644 --- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs +++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs @@ -213,37 +213,6 @@ IContentManifestBuilder AddDependency( /// The builder instance for chaining. IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions); - /// - /// Adds a pre-installation step. - /// - /// Step name. - /// The kind of installation step to execute. - /// Target relative path within workspace. - /// Command arguments for executable steps. - /// Destination relative path for rename operations. - /// Whether elevation is required. - /// Optional user-facing status message. - /// Whether to execute only once and skip on future updates. - /// Optional unique step key for tracking execution. - /// The builder instance for chaining. - IContentManifestBuilder AddPreInstallStep( - string name, - InstallationStepKind kind, - string? targetRelativePath = null, - List? arguments = null, - string? destinationRelativePath = null, - bool requiresElevation = false, - string? statusMessage = null, - bool runOnce = false, - string? stepKey = null); - - /// - /// Adds a pre-installation step using an existing instance. - /// - /// The installation step to add. - /// The builder instance for chaining. - IContentManifestBuilder AddPreInstallStep(InstallationStep step); - /// /// Adds a post-installation step. /// diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs index 1c5e1dba6..b954fcdbd 100644 --- a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs +++ b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs @@ -10,11 +10,6 @@ namespace GenHub.Core.Models.Manifest; /// public class InstallationInstructions { - /// - /// Gets or sets the steps to run before installation. - /// - public List PreInstallSteps { get; set; } = []; - /// /// Gets or sets the steps to run after installation. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 77e8c00f5..d438ccea9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -51,6 +51,7 @@ public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallSte instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(OperationResult.CreateSuccess()); @@ -69,12 +70,12 @@ public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallSte // Assert Assert.True(result.Success); validatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once); - instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", It.IsAny>(), It.IsAny()), Times.Once); + instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", "Test Provider", It.IsAny>(), It.IsAny()), Times.Once); validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once); } /// - /// Verifies that PrepareContentAsync fails when post-install steps fail. + /// Verifies that PrepareContentAsync fails and triggers rollback when post-install steps fail. /// /// A task representing the asynchronous operation. [Fact] @@ -97,6 +98,7 @@ public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync() instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(OperationResult.CreateFailure("Post-install step execution error")); @@ -115,6 +117,53 @@ public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync() // Assert Assert.False(result.Success); Assert.Contains("Post-install step execution error", result.FirstError); + Assert.True(provider.RollbackCalled); + } + + /// + /// Verifies that PrepareContentAsync triggers rollback when post-install steps are canceled. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task PrepareContentAsync_CancelsAndTriggersRollbackAsync() + { + // Arrange + var validatorMock = new Mock(); + var instructionsMock = new Mock(); + var loggerMock = new Mock(); + var discovererMock = new Mock(); + var resolverMock = new Mock(); + var delivererMock = new Mock(); + + var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" }; + var validationResult = new ValidationResult(manifest.Id, new List()); + + validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(validationResult); + + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await provider.PrepareContentAsync(manifest, "/tmp/test"); + }); + + Assert.True(provider.RollbackCalled); } /// @@ -167,6 +216,8 @@ private class TestContentProvider : BaseContentProvider private readonly IContentResolver _resolver; private readonly IContentDeliverer _deliverer; + public bool RollbackCalled { get; private set; } + public TestContentProvider( IContentValidator validator, IInstallationInstructionsService instructionsService, @@ -212,5 +263,15 @@ protected override Task> PrepareContentInternal { return Task.FromResult(OperationResult.CreateSuccess(manifest)); } + + protected override Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + RollbackCalled = true; + return Task.CompletedTask; + } } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index 00249d770..32657ee6a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -50,6 +50,7 @@ public GitHubContentProviderTests() instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(OperationResult.CreateSuccess()); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 6e8da7abb..0ed197111 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -88,17 +88,17 @@ public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess() } /// - /// Verifies that executing installer steps from an untrusted publisher fails. + /// Verifies that executing installer steps from an untrusted provider fails even if manifest metadata claims to be trusted. /// /// A task representing the asynchronous unit test. [Fact] - public async Task ExecutePostInstallStepsAsync_UntrustedPublisher_FailsExecution() + public async Task ExecutePostInstallStepsAsync_UntrustedProvider_FailsExecution() { var manifest = CreateBaseManifest(); manifest.Publisher = new PublisherInfo { - Name = "Untrusted Publisher", - PublisherType = "untrusted_source", + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, }; manifest.InstallationInstructions = new InstallationInstructions { @@ -113,7 +113,35 @@ public async Task ExecutePostInstallStepsAsync_UntrustedPublisher_FailsExecution ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + // Manifest claims GeneralsOnline, but providerSource is untrusted + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source"); + + Assert.False(result.Success); + Assert.Contains("not authorized to execute installation steps", result.FirstError); + } + + /// + /// Verifies that mutating steps like RemoveFile and RenameFile fail when provider is untrusted. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_FailExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Delete Something", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = "important.dat", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source"); Assert.False(result.Success); Assert.Contains("not authorized to execute installation steps", result.FirstError); @@ -145,7 +173,7 @@ public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecutio ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.False(result.Success); Assert.Contains("escapes the working directory", result.FirstError); @@ -182,7 +210,7 @@ public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution( ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.False(result.Success); Assert.Contains("not declared in manifest files", result.FirstError); @@ -230,7 +258,7 @@ public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution() ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.False(result.Success); Assert.Contains("Integrity verification failed", result.FirstError); @@ -261,7 +289,7 @@ public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile() ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.True(result.Success); Assert.False(File.Exists(fullPath)); @@ -298,7 +326,7 @@ public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.True(result.Success); Assert.False(File.Exists(sourceFullPath)); @@ -364,7 +392,7 @@ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotific ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.True(result.Success); Assert.True(_userSettings.IsInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey)); @@ -420,7 +448,7 @@ public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsE // Mark as already executed _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.True(result.Success); @@ -491,7 +519,7 @@ public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_Executes _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); // Force execution - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, force: true); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline, force: true); Assert.True(result.Success); _notificationServiceMock.Verify( @@ -523,12 +551,123 @@ public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure() ], }; - var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); Assert.False(result.Success); Assert.Contains("Unsupported installation step kind", result.FirstError); } + /// + /// Verifies that elevated steps fail with an unsupported result on non-Windows platforms. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_ElevationOnNonWindows_ReturnsFailure() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var scriptName = "elevated_script.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + const string expectedHash = "elevated_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Elevated Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + RequiresElevation = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("requires administrator elevation, which is only supported on Windows", result.FirstError); + } + + /// + /// Verifies that caller cancellation terminates the running child installer process. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProcessAndThrows() + { + var scriptName = OperatingSystem.IsWindows() ? "sleep_installer.bat" : "sleep_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + File.WriteAllText(fullPath, "@echo off\r\nping -n 30 127.0.0.1 > nul\r\n"); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nsleep 30\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "sleep_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Long Running Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + }, + ], + }; + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(200)); + + await Assert.ThrowsAnyAsync(async () => + { + await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline, + cancellationToken: cts.Token); + }); + } + private static ContentManifest CreateBaseManifest() => new() { Id = "1.0.test.gameclient.variant", diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs index 51757b2b4..eafa0df4c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs @@ -262,24 +262,6 @@ public void AddPostInstallStep_AddsStepCorrectly() Assert.Equal(["install", "12345"], step.Arguments); } - /// - /// Tests that AddPreInstallStep adds a structured installation step. - /// - [Fact] - public void AddPreInstallStep_AddsStepCorrectly() - { - var result = _builder - .WithBasicInfo("Test Publisher", "Test Name", "1") - .AddPreInstallStep("Clean Old File", InstallationStepKind.RemoveFile, "old_file.tmp") - .Build(); - - Assert.NotNull(result.InstallationInstructions); - var step = Assert.Single(result.InstallationInstructions.PreInstallSteps); - Assert.Equal("Clean Old File", step.Name); - Assert.Equal(InstallationStepKind.RemoveFile, step.Kind); - Assert.Equal("old_file.tmp", step.TargetRelativePath); - } - /// /// Tests that Build returns a valid manifest with minimal configuration. /// diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 2f612ffde..ff1fe25f9 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -158,17 +158,34 @@ public virtual async Task> PrepareContentAsync( { if (installationInstructionsService != null) { - // Execute post-installation steps if declared on the delivered manifest - var stepExecutionResult = await installationInstructionsService.ExecutePostInstallStepsAsync( - result.Data, - workingDirectory, - progress: progress, - cancellationToken: cancellationToken); - - if (!stepExecutionResult.Success) + try { - Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); - return OperationResult.CreateFailure(stepExecutionResult.Errors); + // Execute post-installation steps if declared on the delivered manifest + var stepExecutionResult = await installationInstructionsService.ExecutePostInstallStepsAsync( + result.Data, + workingDirectory, + providerSource: SourceName, + progress: progress, + cancellationToken: cancellationToken); + + if (!stepExecutionResult.Success) + { + Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); + await RollbackPreparedContentAsync(manifest, result.Data, workingDirectory, CancellationToken.None); + return OperationResult.CreateFailure(stepExecutionResult.Errors); + } + } + catch (OperationCanceledException) + { + Logger.LogInformation("Post-installation execution was canceled for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await RollbackPreparedContentAsync(manifest, result.Data, workingDirectory, CancellationToken.None); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error executing post-installation steps for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await RollbackPreparedContentAsync(manifest, result.Data, workingDirectory, CancellationToken.None); + return OperationResult.CreateFailure($"Post-installation execution failed: {ex.Message}"); } } @@ -223,6 +240,23 @@ public virtual async Task> PrepareContentAsync( } } + /// + /// Rolls back prepared content and registered manifests when post-preparation steps fail. + /// + /// The original requested manifest. + /// The prepared manifest returned by PrepareContentInternalAsync. + /// The working directory where content was prepared. + /// A token to cancel rollback operations. + /// A task representing the asynchronous operation. + protected virtual Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + /// /// Gets the logger for this provider. /// diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 5fd783242..f8b92ec41 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -730,7 +730,6 @@ private InstallationInstructions BuildInstallationInstructions( { WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, DownloadHash = manifest.InstallationInstructions?.DownloadHash, - PreInstallSteps = [.. manifest.InstallationInstructions?.PreInstallSteps ?? []], PostInstallSteps = [.. inheritedPostSteps], }; diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index c4c298346..453207933 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -242,4 +242,43 @@ protected override async Task> PrepareContentIn $"Content preparation failed: {ex.Message}"); } } + + /// + protected override async Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + Logger.LogWarning("Rolling back Generals Online manifest registration for version {Version}", preparedManifest.Version); + + try + { + var allManifestsResult = await manifestPool.GetAllManifestsAsync(cancellationToken); + if (allManifestsResult.Success && allManifestsResult.Data != null) + { + var matchingManifests = allManifestsResult.Data + .Where(m => string.Equals(m.Version, preparedManifest.Version, StringComparison.OrdinalIgnoreCase) && + string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var manifest in matchingManifests) + { + var removeResult = await manifestPool.RemoveManifestAsync(manifest.Id, cancellationToken: cancellationToken); + if (!removeResult.Success) + { + Logger.LogWarning("Failed to remove manifest {ManifestId} during rollback: {Error}", manifest.Id, removeResult.FirstError); + } + else + { + Logger.LogInformation("Unregistered manifest {ManifestId} during rollback", manifest.Id); + } + } + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Exception occurred during Generals Online manifest rollback for version {Version}", preparedManifest.Version); + } + } } diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index f6acbff8e..bf3c6095f 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -52,61 +52,22 @@ public InstallationInstructionsService( { } - /// - public Task ExecutePreInstallStepsAsync( - ContentManifest manifest, - string workingDirectory, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - return ExecutePreInstallStepsAsync(manifest, workingDirectory, force: false, progress, cancellationToken); - } - - /// - public async Task ExecutePreInstallStepsAsync( - ContentManifest manifest, - string workingDirectory, - bool force, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(manifest); - - if (manifest.InstallationInstructions?.PreInstallSteps == null || - manifest.InstallationInstructions.PreInstallSteps.Count == 0) - { - return OperationResult.CreateSuccess(); - } - - logger.LogInformation( - "Executing {Count} pre-install step(s) for manifest {ManifestId} (force: {Force})", - manifest.InstallationInstructions.PreInstallSteps.Count, - manifest.Id, - force); - - return await ExecuteStepsAsync( - manifest.InstallationInstructions.PreInstallSteps, - manifest, - workingDirectory, - force, - progress, - cancellationToken); - } - /// public Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + string? providerSource = null, IProgress? progress = null, CancellationToken cancellationToken = default) { - return ExecutePostInstallStepsAsync(manifest, workingDirectory, force: false, progress, cancellationToken); + return ExecutePostInstallStepsAsync(manifest, workingDirectory, providerSource, force: false, progress, cancellationToken); } /// public async Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, + string? providerSource, bool force, IProgress? progress = null, CancellationToken cancellationToken = default) @@ -120,15 +81,17 @@ public async Task ExecutePostInstallStepsAsync( } logger.LogInformation( - "Executing {Count} post-install step(s) for manifest {ManifestId} (force: {Force})", + "Executing {Count} post-install step(s) for manifest {ManifestId} from provider {Provider} (force: {Force})", manifest.InstallationInstructions.PostInstallSteps.Count, manifest.Id, + providerSource ?? "unspecified", force); return await ExecuteStepsAsync( manifest.InstallationInstructions.PostInstallSteps, manifest, workingDirectory, + providerSource, force, progress, cancellationToken); @@ -138,6 +101,7 @@ private async Task ExecuteStepsAsync( IReadOnlyList steps, ContentManifest manifest, string workingDirectory, + string? providerSource, bool force, IProgress? progress, CancellationToken cancellationToken) @@ -157,7 +121,7 @@ private async Task ExecuteStepsAsync( continue; } - var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, force, progress, cancellationToken); + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, cancellationToken); if (!stepResult.Success) { return stepResult; @@ -171,10 +135,17 @@ private async Task ExecuteSingleStepAsync( InstallationStep step, ContentManifest manifest, string workingDirectory, + string? providerSource, bool force, IProgress? progress, CancellationToken cancellationToken) { + var authResult = ValidateProviderAuthorization(providerSource, manifest, step); + if (!authResult.Success) + { + return authResult; + } + var stepKey = GetStepKey(step, manifest); if (!force && step.RunOnce && await ShouldSkipStepAsync(step, stepKey, manifest, cancellationToken)) @@ -291,12 +262,6 @@ private async Task ExecuteRunVerifiedInstallerAsync( IProgress? progress, CancellationToken cancellationToken) { - var authResult = ValidatePublisherAuthorization(manifest, step); - if (!authResult.Success) - { - return authResult; - } - var pathResult = ValidateInstallerTargetPath(step, workingDirectory, out var targetFullPath); if (!pathResult.Success) { @@ -320,25 +285,25 @@ private async Task ExecuteRunVerifiedInstallerAsync( return await RunInstallerProcessAsync(step, targetFullPath, workingDirectory, cancellationToken); } - private OperationResult ValidatePublisherAuthorization(ContentManifest manifest, InstallationStep step) + private OperationResult ValidateProviderAuthorization(string? providerSource, ContentManifest manifest, InstallationStep step) { - var publisherType = manifest.Publisher?.PublisherType ?? string.Empty; - var publisherName = manifest.Publisher?.Name ?? string.Empty; + var effectiveSource = !string.IsNullOrWhiteSpace(providerSource) + ? providerSource + : string.Empty; - var isTrusted = PublisherTypeConstants.TrustedExecutablePublishers.Contains(publisherType) || - PublisherTypeConstants.TrustedExecutablePublishers.Contains(publisherName); + var isTrusted = PublisherTypeConstants.TrustedExecutablePublishers.Contains(effectiveSource); if (!isTrusted) { logger.LogError( - "Untrusted publisher '{PublisherType}' ({PublisherName}) attempted to execute installer step '{StepName}' for manifest {ManifestId}", - publisherType, - publisherName, + "Untrusted provider '{ProviderSource}' attempted to execute step '{StepName}' (Kind: {Kind}) for manifest {ManifestId}", + effectiveSource, step.Name, + step.Kind, manifest.Id); return OperationResult.CreateFailure( - $"Publisher '{(!string.IsNullOrEmpty(publisherType) ? publisherType : publisherName)}' is not authorized to execute installation steps."); + $"Provider '{(!string.IsNullOrEmpty(effectiveSource) ? effectiveSource : "unknown")}' is not authorized to execute installation steps."); } return OperationResult.CreateSuccess(); @@ -356,7 +321,7 @@ private OperationResult ValidateInstallerTargetPath(InstallationStep step, strin var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); - if (!PathHelper.IsPathContainedIn(targetFullPath, workingDirectory)) + if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath)) { logger.LogError("Target installer path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); return OperationResult.CreateFailure($"Installer path '{step.TargetRelativePath}' escapes the working directory."); @@ -454,8 +419,15 @@ private async Task RunInstallerProcessAsync( } } - if (step.RequiresElevation && OperatingSystem.IsWindows()) + if (step.RequiresElevation) { + if (!OperatingSystem.IsWindows()) + { + logger.LogError("Installation step '{StepName}' requires administrator elevation, which is only supported on Windows", step.Name); + return OperationResult.CreateFailure( + $"Installation step '{step.Name}' requires administrator elevation, which is only supported on Windows."); + } + startInfo.UseShellExecute = true; startInfo.Verb = "runas"; } @@ -500,6 +472,23 @@ private async Task RunInstallerProcessAsync( notificationService.ShowError("Installation Step Failed", $"Step '{step.Name}' timed out."); return OperationResult.CreateFailure($"Installation step '{step.Name}' timed out."); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation("Installation step '{StepName}' was canceled by caller, killing process tree", step.Name); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception killEx) + { + logger.LogWarning(killEx, "Failed to terminate canceled installer step '{StepName}'", step.Name); + } + + throw; + } if (process.ExitCode != 0) { @@ -549,7 +538,7 @@ private OperationResult ExecuteRemoveFile(InstallationStep step, string workingD var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); var targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); - if (!PathHelper.IsPathContainedIn(targetFullPath, workingDirectory)) + if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath)) { logger.LogError("Target remove path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); return OperationResult.CreateFailure($"Target file '{step.TargetRelativePath}' escapes the working directory."); @@ -594,13 +583,13 @@ private OperationResult ExecuteRenameFile(InstallationStep step, string workingD var sourceFullPath = Path.Combine(workingDirectory, normalizedSourcePath); var destFullPath = Path.Combine(workingDirectory, normalizedDestPath); - if (!PathHelper.IsPathContainedIn(sourceFullPath, workingDirectory)) + if (!PathHelper.IsPathWithinDirectory(workingDirectory, sourceFullPath)) { logger.LogError("Source path '{Source}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); return OperationResult.CreateFailure($"Source path '{step.TargetRelativePath}' escapes the working directory."); } - if (!PathHelper.IsPathContainedIn(destFullPath, workingDirectory)) + if (!PathHelper.IsPathWithinDirectory(workingDirectory, destFullPath)) { logger.LogError("Destination path '{Dest}' escapes working directory '{Dir}'", step.DestinationRelativePath, workingDirectory); return OperationResult.CreateFailure($"Destination path '{step.DestinationRelativePath}' escapes the working directory."); diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index de5a1f162..320b1f003 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -625,50 +625,12 @@ public IContentManifestBuilder WithInstallationInstructions(InstallationInstruct { _manifest.InstallationInstructions = installationInstructions ?? new InstallationInstructions(); logger.LogDebug( - "Set installation instructions with strategy {Strategy}, {PreCount} pre-install steps, {PostCount} post-install steps", + "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps", _manifest.InstallationInstructions.WorkspaceStrategy, - _manifest.InstallationInstructions.PreInstallSteps.Count, _manifest.InstallationInstructions.PostInstallSteps.Count); return this; } - /// - public IContentManifestBuilder AddPreInstallStep( - string name, - InstallationStepKind kind, - string? targetRelativePath = null, - List? arguments = null, - string? destinationRelativePath = null, - bool requiresElevation = false, - string? statusMessage = null, - bool runOnce = false, - string? stepKey = null) - { - var step = new InstallationStep - { - Name = name, - Kind = kind, - TargetRelativePath = targetRelativePath, - Arguments = arguments, - DestinationRelativePath = destinationRelativePath, - RequiresElevation = requiresElevation, - StatusMessage = statusMessage, - RunOnce = runOnce, - StepKey = stepKey, - }; - return AddPreInstallStep(step); - } - - /// - public IContentManifestBuilder AddPreInstallStep(InstallationStep step) - { - ArgumentNullException.ThrowIfNull(step); - _manifest.InstallationInstructions ??= new InstallationInstructions(); - _manifest.InstallationInstructions.PreInstallSteps.Add(step); - logger.LogDebug("Added pre-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce); - return this; - } - /// public IContentManifestBuilder AddPostInstallStep( string name, From c9f542628245af0ba139f968925704aef964c653 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 16:15:29 +0000 Subject: [PATCH 14/26] fix(core): resolve DeepSource lambda expressions and constructor accessibility Convert BaseContentProvider constructor to protected for abstract class and simplify test assert lambdas. --- .../Content/BaseContentProviderTests.cs | 5 +-- .../InstallationInstructionsServiceTests.cs | 8 ++--- .../ContentProviders/BaseContentProvider.cs | 36 +++++++++++++------ 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index d438ccea9..dc6903dca 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -158,10 +158,7 @@ public async Task PrepareContentAsync_CancelsAndTriggersRollbackAsync() delivererMock.Object); // Act & Assert - await Assert.ThrowsAsync(async () => - { - await provider.PrepareContentAsync(manifest, "/tmp/test"); - }); + await Assert.ThrowsAsync(() => provider.PrepareContentAsync(manifest, "/tmp/test")); Assert.True(provider.RollbackCalled); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 0ed197111..541bcef58 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -658,14 +658,12 @@ public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProc using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromMilliseconds(200)); - await Assert.ThrowsAnyAsync(async () => - { - await _service.ExecutePostInstallStepsAsync( + await Assert.ThrowsAnyAsync(() => + _service.ExecutePostInstallStepsAsync( manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline, - cancellationToken: cts.Token); - }); + cancellationToken: cts.Token)); } private static ContentManifest CreateBaseManifest() => new() diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index ff1fe25f9..6e0a74262 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -18,12 +18,28 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// /// Base class for content providers with common pipeline orchestration logic. /// -public abstract class BaseContentProvider( - IContentValidator contentValidator, - IInstallationInstructionsService? installationInstructionsService, - ILogger logger -) : IContentProvider +public abstract class BaseContentProvider : IContentProvider { + private readonly IContentValidator _contentValidator; + private readonly IInstallationInstructionsService? _installationInstructionsService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The content validator. + /// The optional installation instructions service. + /// The logger. + protected BaseContentProvider( + IContentValidator contentValidator, + IInstallationInstructionsService? installationInstructionsService, + ILogger logger) + { + _contentValidator = contentValidator; + _installationInstructionsService = installationInstructionsService; + _logger = logger; + } + /// /// Initializes a new instance of the class without an installation instructions service. /// @@ -156,12 +172,12 @@ public virtual async Task> PrepareContentAsync( if (result.Success && result.Data != null) { - if (installationInstructionsService != null) + if (_installationInstructionsService != null) { try { // Execute post-installation steps if declared on the delivered manifest - var stepExecutionResult = await installationInstructionsService.ExecutePostInstallStepsAsync( + var stepExecutionResult = await _installationInstructionsService.ExecutePostInstallStepsAsync( result.Data, workingDirectory, providerSource: SourceName, @@ -260,17 +276,17 @@ protected virtual Task RollbackPreparedContentAsync( /// /// Gets the logger for this provider. /// - protected ILogger Logger => logger; + protected ILogger Logger => _logger; /// /// Gets the content validator for manifest validation. /// - protected IContentValidator ContentValidator => contentValidator; + protected IContentValidator ContentValidator => _contentValidator; /// /// Gets the installation instructions service for post-install execution. /// - protected IInstallationInstructionsService? InstallationInstructionsService => installationInstructionsService; + protected IInstallationInstructionsService? InstallationInstructionsService => _installationInstructionsService; /// /// Gets the discoverer for this provider. From 2c03c14c113e62890bf6813a0b9c3b61e28181ac Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 16:58:40 +0000 Subject: [PATCH 15/26] fix(content): harden installation instructions step lifecycle and delivery routing - Add JsonConverter attribute to InstallationStepKind enum - Batch and persist RunOnce step execution keys only after all steps succeed - Scope EAC precondition to Generals Online publisher manifests - Preserve InstallTarget and DownloadUrl when delivering HTTP files - Prevent caller-owned InstallationInstructions mutation during builder chaining - Scope Generals Online manifest registration rollback to newly added manifests - Expand EAC inherited step and mutation security test coverage --- GenHub/GenHub.Core/Helpers/PathHelper.cs | 20 ------ .../Models/Enums/InstallationStepKind.cs | 3 + .../InstallationInstructionsServiceTests.cs | 19 +++++- .../GeneralsOnlineManifestFactoryEacTests.cs | 67 ++++++++++++++++++- .../Helpers/PathHelperTests.cs | 43 ------------ .../ContentDeliverers/HttpContentDeliverer.cs | 53 ++++++++------- .../EasyAntiCheatPrecondition.cs | 12 +++- .../GeneralsOnline/GeneralsOnlineProvider.cs | 31 +++++++-- .../InstallationInstructionsService.cs | 55 ++++++++------- .../Manifest/ContentManifestBuilder.cs | 32 ++++++++- 10 files changed, 208 insertions(+), 127 deletions(-) diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index 82e60c330..e1314186c 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -95,26 +95,6 @@ public static bool IsPathWithinDirectory(string baseDirectory, string candidateP } } - /// - /// Determines whether a candidate path resolves to a location inside a base directory. - /// Both paths are fully normalized first, so .. segments, redundant separators and - /// rooted candidates cannot escape the base directory. Because normalization is textual and a - /// symbolic link or junction redirects a path that reads as contained, both sides are also - /// compared after their links are followed; a path that cannot be resolved — because it does - /// not exist yet, or the filesystem refuses the query — is compared as written. - /// - /// The directory that must contain the candidate path. - /// The path to test for containment. - /// when the candidate resolves inside the base directory; otherwise, . - public static bool IsPathWithinDirectory(string baseDirectory, string candidatePath) - { - var normalizedRoot = Path.GetFullPath(baseDirectory); - var normalizedTarget = Path.GetFullPath(candidatePath); - - return IsContained(normalizedRoot, normalizedTarget) && - IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); - } - /// /// Normalizes a relative path by standardizing directory separators and removing leading separators. /// diff --git a/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs index 9e0bf7827..1389e130b 100644 --- a/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs +++ b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs @@ -1,8 +1,11 @@ +using System.Text.Json.Serialization; + namespace GenHub.Core.Models.Enums; /// /// Defines the supported kind of installation operation in manifest-declared installation steps. /// +[JsonConverter(typeof(JsonStringEnumConverter))] public enum InstallationStepKind { /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 541bcef58..a68a9de44 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -121,12 +121,19 @@ public async Task ExecutePostInstallStepsAsync_UntrustedProvider_FailsExecution( } /// - /// Verifies that mutating steps like RemoveFile and RenameFile fail when provider is untrusted. + /// Verifies that mutating steps like RemoveFile and RenameFile fail and do not modify files on disk when provider is untrusted. /// /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_FailExecution() { + var importantFilePath = Path.Combine(_tempDirectory, "important.dat"); + var sourceFilePath = Path.Combine(_tempDirectory, "source.dat"); + var destFilePath = Path.Combine(_tempDirectory, "dest.dat"); + + await File.WriteAllTextAsync(importantFilePath, "important content"); + await File.WriteAllTextAsync(sourceFilePath, "source content"); + var manifest = CreateBaseManifest(); manifest.InstallationInstructions = new InstallationInstructions { @@ -138,6 +145,13 @@ public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_F Kind = InstallationStepKind.RemoveFile, TargetRelativePath = "important.dat", }, + new InstallationStep + { + Name = "Rename Something", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "source.dat", + DestinationRelativePath = "dest.dat", + }, ], }; @@ -145,6 +159,9 @@ public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_F Assert.False(result.Success); Assert.Contains("not authorized to execute installation steps", result.FirstError); + Assert.True(File.Exists(importantFilePath)); + Assert.True(File.Exists(sourceFilePath)); + Assert.False(File.Exists(destFilePath)); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs index bf53abe47..443824a51 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs @@ -175,6 +175,69 @@ public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_DoesNotC Assert.Null(eacStep); } + /// + /// Verifies that an inherited EAC step is not duplicated when EAC portable layout already contains the setup executable. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutablePresent_DoesNotDuplicateEacStepAsync() + { + WriteEacPortableLayout(); + + var originalManifest = CreateOriginalManifest(); + originalManifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }, + ], + }; + + var gameClient = await CreateGameClientManifestAsync(originalManifest); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacSteps = gameClient.InstallationInstructions.PostInstallSteps.Where(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)).ToList(); + Assert.Single(eacSteps); + } + + /// + /// Verifies that an inherited EAC step is dropped when the setup executable is absent in extracted content. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutableAbsent_DropsEacStepAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var originalManifest = CreateOriginalManifest(); + originalManifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }, + ], + }; + + var gameClient = await CreateGameClientManifestAsync(originalManifest); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)); + Assert.Null(eacStep); + } + /// public void Dispose() { @@ -216,7 +279,7 @@ private void WriteFile(string relativePath) File.WriteAllText(fullPath, relativePath); } - private async Task CreateGameClientManifestAsync() + private async Task CreateGameClientManifestAsync(ContentManifest? originalManifest = null) { var providerLoader = new Mock(); var factory = new GeneralsOnlineManifestFactory( @@ -224,7 +287,7 @@ private async Task CreateGameClientManifestAsync() providerLoader.Object); var manifests = await factory.CreateManifestsFromExtractedContentAsync( - CreateOriginalManifest(), + originalManifest ?? CreateOriginalManifest(), _extractedDirectory); return manifests.Single(manifest => manifest.ContentType == ContentType.GameClient); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index 5fa3fe505..a329cf67f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -144,49 +144,6 @@ public void IsPathWithinDirectory_AcceptsCandidateBehindASymbolicLinkThatStaysIn } } - /// - /// Verifies that IsPathContainedIn correctly identifies paths inside a container directory. - /// - [Fact] - public void IsPathContainedIn_ReturnsTrue_ForValidDescendants() - { - var container = Path.Combine(Path.GetTempPath(), "GenHubWorkspace"); - var childFile = Path.Combine(container, "subfolder", "file.exe"); - - var result = PathHelper.IsPathContainedIn(childFile, container); - - Assert.True(result); - } - - /// - /// Verifies that IsPathContainedIn returns false when a path attempts directory traversal outside container. - /// - [Fact] - public void IsPathContainedIn_ReturnsFalse_ForPathTraversal() - { - var container = Path.Combine(Path.GetTempPath(), "GenHubWorkspace"); - var escapedFile = Path.Combine(container, "..", "escaped.exe"); - - var result = PathHelper.IsPathContainedIn(escapedFile, container); - - Assert.False(result); - } - - /// - /// Verifies that IsPathContainedIn returns false for sibling directory with common prefix. - /// - [Fact] - public void IsPathContainedIn_ReturnsFalse_ForSiblingDirectoryWithSharedPrefix() - { - var temp = Path.GetTempPath(); - var container = Path.Combine(temp, "GenHubWorkspace"); - var sibling = Path.Combine(temp, "GenHubWorkspaceSibling", "file.exe"); - - var result = PathHelper.IsPathContainedIn(sibling, container); - - Assert.False(result); - } - /// /// Verifies that NormalizeRelativePath standardizes path separators. /// diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs index e63e225cf..d16761c22 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs @@ -131,39 +131,40 @@ public async Task> DeliverContentAsync( $"Failed to download {file.RelativePath}: {downloadResult.FirstError}"); } - // Add the delivered file using the builder preserving hash - if (!string.IsNullOrEmpty(file.Hash)) + // Add the delivered file preserving InstallTarget, DownloadUrl, and hash + var fileInfo = new FileInfo(localPath); + var deliveredFile = new ManifestFile { - var fileInfo = new FileInfo(localPath); - await deliveredManifest.AddContentAddressableFileAsync( - file.RelativePath, - file.Hash, - fileInfo.Exists ? fileInfo.Length : file.Size, - isExecutable: file.IsExecutable, - permissions: file.Permissions); - } - else - { - await deliveredManifest.AddRemoteFileAsync( - file.RelativePath, - file.DownloadUrl ?? string.Empty, - ContentSourceType.ContentAddressable, - isExecutable: file.IsExecutable, - permissions: file.Permissions); - } + RelativePath = file.RelativePath, + SourceType = ContentSourceType.ContentAddressable, + InstallTarget = file.InstallTarget, + IsExecutable = file.IsExecutable, + Hash = !string.IsNullOrEmpty(file.Hash) ? file.Hash : string.Empty, + DownloadUrl = file.DownloadUrl, + Size = fileInfo.Exists ? fileInfo.Length : file.Size, + Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, + }; + deliveredManifest.AddFile(deliveredFile); processedFiles++; } - // Add any other files (without DownloadUrl) as-is + // Add any other files (without DownloadUrl) preserving metadata foreach (var file in packageManifest.Files.Where(f => string.IsNullOrEmpty(f.DownloadUrl))) { - await deliveredManifest.AddLocalFileAsync( - file.RelativePath, - file.SourcePath ?? string.Empty, - ContentSourceType.ContentAddressable, - isExecutable: file.IsExecutable, - permissions: file.Permissions); + var otherFile = new ManifestFile + { + RelativePath = file.RelativePath, + SourcePath = file.SourcePath ?? string.Empty, + SourceType = ContentSourceType.ContentAddressable, + InstallTarget = file.InstallTarget, + IsExecutable = file.IsExecutable, + Hash = file.Hash, + DownloadUrl = file.DownloadUrl, + Size = file.Size, + Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, + }; + deliveredManifest.AddFile(otherFile); } // Add required directories diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs index 17efe65d6..9415a242b 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -17,7 +17,7 @@ public class EasyAntiCheatPrecondition : IInstallationStepPrecondition /// public bool CanHandle(InstallationStep step, ContentManifest manifest) { - if (!OperatingSystem.IsWindows() || step == null) + if (!OperatingSystem.IsWindows() || step == null || manifest == null) { return false; } @@ -27,6 +27,16 @@ public bool CanHandle(InstallationStep step, ContentManifest manifest) return false; } + var isGeneralsOnline = string.Equals( + manifest.Publisher?.PublisherType, + PublisherTypeConstants.GeneralsOnline, + StringComparison.OrdinalIgnoreCase); + + if (!isGeneralsOnline) + { + return false; + } + var fileName = Path.GetFileName(step.TargetRelativePath ?? string.Empty); return string.Equals(fileName, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase); } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index 453207933..a0a081018 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -1,3 +1,9 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Manifest; @@ -10,11 +16,6 @@ using GenHub.Core.Models.Results.Content; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace GenHub.Features.Content.Services.GeneralsOnline; @@ -33,6 +34,7 @@ public class GeneralsOnlineProvider( ILogger logger) : BaseContentProvider(contentValidator, installationInstructionsService, logger) { + private readonly ConcurrentDictionary> _preExistingManifestIdsByManifest = new(StringComparer.OrdinalIgnoreCase); private ProviderDefinition? _cachedProviderDefinition; /// @@ -213,6 +215,18 @@ protected override async Task> PrepareContentIn $"Cannot deliver content for manifest {manifest.Id}"); } + var existingPool = await manifestPool.GetAllManifestsAsync(cancellationToken); + var preExisting = new HashSet(StringComparer.OrdinalIgnoreCase); + if (existingPool.Success && existingPool.Data != null) + { + foreach (var m in existingPool.Data) + { + preExisting.Add(m.Id); + } + } + + _preExistingManifestIdsByManifest[manifest.Id] = preExisting; + var deliveryResult = await Deliverer.DeliverContentAsync( manifest, workingDirectory, @@ -254,12 +268,15 @@ protected override async Task RollbackPreparedContentAsync( try { + _preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out var preExistingIds); + var allManifestsResult = await manifestPool.GetAllManifestsAsync(cancellationToken); if (allManifestsResult.Success && allManifestsResult.Data != null) { var matchingManifests = allManifestsResult.Data .Where(m => string.Equals(m.Version, preparedManifest.Version, StringComparison.OrdinalIgnoreCase) && - string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) + string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase) && + (preExistingIds == null || !preExistingIds.Contains(m.Id))) .ToList(); foreach (var manifest in matchingManifests) @@ -278,7 +295,7 @@ protected override async Task RollbackPreparedContentAsync( } catch (Exception ex) { - Logger.LogWarning(ex, "Exception occurred during Generals Online manifest rollback for version {Version}", preparedManifest.Version); + Logger.LogError(ex, "Error occurred during Generals Online manifest registration rollback"); } } } diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index bf3c6095f..1768f7e05 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -111,6 +111,8 @@ private async Task ExecuteStepsAsync( return OperationResult.CreateFailure($"Working directory does not exist: '{workingDirectory}'"); } + var keysToRecord = new List(); + for (var i = 0; i < steps.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -121,13 +123,33 @@ private async Task ExecuteStepsAsync( continue; } - var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, cancellationToken); + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, keysToRecord, cancellationToken); if (!stepResult.Success) { return stepResult; } } + if (userSettingsService != null && keysToRecord.Count > 0) + { + userSettingsService.Update(s => + { + foreach (var key in keysToRecord) + { + s.RecordInstallationStepExecuted(key); + } + }); + + try + { + await userSettingsService.SaveAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist executed installation step keys"); + } + } + return OperationResult.CreateSuccess(); } @@ -138,6 +160,7 @@ private async Task ExecuteSingleStepAsync( string? providerSource, bool force, IProgress? progress, + List keysToRecord, CancellationToken cancellationToken) { var authResult = ValidateProviderAuthorization(providerSource, manifest, step); @@ -148,7 +171,7 @@ private async Task ExecuteSingleStepAsync( var stepKey = GetStepKey(step, manifest); - if (!force && step.RunOnce && await ShouldSkipStepAsync(step, stepKey, manifest, cancellationToken)) + if (!force && step.RunOnce && ShouldSkipStep(step, stepKey, manifest, keysToRecord)) { logger.LogInformation( "Skipping installation step '{StepName}' for manifest {ManifestId} because it has already been executed (key: {StepKey})", @@ -186,27 +209,19 @@ private async Task ExecuteSingleStepAsync( return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); } - if (result.Success && step.RunOnce && userSettingsService != null && !string.IsNullOrWhiteSpace(stepKey)) + if (result.Success && step.RunOnce && !string.IsNullOrWhiteSpace(stepKey) && !keysToRecord.Contains(stepKey)) { - userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); - try - { - await userSettingsService.SaveAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to persist executed installation step key '{StepKey}'", stepKey); - } + keysToRecord.Add(stepKey); } return result; } - private async Task ShouldSkipStepAsync( + private bool ShouldSkipStep( InstallationStep step, string stepKey, ContentManifest manifest, - CancellationToken cancellationToken) + List keysToRecord) { if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true) { @@ -219,17 +234,9 @@ private async Task ShouldSkipStepAsync( { if (precondition.CanHandle(step, manifest) && precondition.IsAlreadyFulfilled(step, manifest)) { - if (userSettingsService != null && !string.IsNullOrWhiteSpace(stepKey)) + if (!string.IsNullOrWhiteSpace(stepKey) && !keysToRecord.Contains(stepKey)) { - userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); - try - { - await userSettingsService.SaveAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to persist detected installation step key '{StepKey}'", stepKey); - } + keysToRecord.Add(stepKey); } return true; diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index 320b1f003..a55b8603d 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -512,6 +512,7 @@ public Task AddContentAddressableFileAsync( { RelativePath = relativePath, SourceType = ContentSourceType.ContentAddressable, + InstallTarget = DetermineInstallTarget(relativePath), IsExecutable = isExecutable, Hash = hash, Size = size, @@ -614,8 +615,20 @@ public IContentManifestBuilder AddRequiredDirectories(params string[] directorie public IContentManifestBuilder WithInstallationInstructions( WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy) { - _manifest.InstallationInstructions ??= new InstallationInstructions(); - _manifest.InstallationInstructions.WorkspaceStrategy = workspaceStrategy; + if (_manifest.InstallationInstructions == null) + { + _manifest.InstallationInstructions = new InstallationInstructions { WorkspaceStrategy = workspaceStrategy }; + } + else + { + _manifest.InstallationInstructions = new InstallationInstructions + { + WorkspaceStrategy = workspaceStrategy, + DownloadHash = _manifest.InstallationInstructions.DownloadHash, + PostInstallSteps = [.. _manifest.InstallationInstructions.PostInstallSteps], + }; + } + logger.LogDebug("Set workspace strategy: {Strategy}", workspaceStrategy); return this; } @@ -623,7 +636,20 @@ public IContentManifestBuilder WithInstallationInstructions( /// public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions) { - _manifest.InstallationInstructions = installationInstructions ?? new InstallationInstructions(); + if (installationInstructions == null) + { + _manifest.InstallationInstructions = new InstallationInstructions(); + } + else + { + _manifest.InstallationInstructions = new InstallationInstructions + { + WorkspaceStrategy = installationInstructions.WorkspaceStrategy, + DownloadHash = installationInstructions.DownloadHash, + PostInstallSteps = [.. installationInstructions.PostInstallSteps], + }; + } + logger.LogDebug( "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps", _manifest.InstallationInstructions.WorkspaceStrategy, From ad06b4192b6448fcc2708f87e6c1fa5a51ec9986 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 17:25:32 +0000 Subject: [PATCH 16/26] fix(security): resolve all intermediate path components in symlink containment check --- GenHub/GenHub.Core/Helpers/PathHelper.cs | 60 ++++++++++--------- .../Helpers/PathHelperTests.cs | 34 +++++++++++ .../CommunityOutpostProvider.cs | 4 +- .../Publishers/SuperHackersProvider.cs | 24 +------- 4 files changed, 69 insertions(+), 53 deletions(-) diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index e1314186c..1d0106afc 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -127,41 +127,45 @@ private static string FollowLinks(string fullPath) { try { - var existing = fullPath; - var remainder = string.Empty; + var normalized = Path.GetFullPath(fullPath); + var root = Path.GetPathRoot(normalized); + if (string.IsNullOrEmpty(root)) + { + return normalized; + } - while (!Directory.Exists(existing) && !File.Exists(existing)) + var relativeFromRoot = Path.GetRelativePath(root, normalized); + if (relativeFromRoot == "." || relativeFromRoot.Length == 0) { - var parent = Path.GetDirectoryName(existing); - if (string.IsNullOrEmpty(parent)) + return root; + } + + var segments = relativeFromRoot.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + var current = root; + foreach (var segment in segments) + { + current = Path.Combine(current, segment); + + if (Directory.Exists(current) || File.Exists(current)) { - return fullPath; + FileSystemInfo info = Directory.Exists(current) + ? new DirectoryInfo(current) + : new FileInfo(current); + + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + current = target.FullName; + } } - - remainder = Path.Combine(Path.GetFileName(existing), remainder); - existing = parent; } - FileSystemInfo info = Directory.Exists(existing) - ? new DirectoryInfo(existing) - : new FileInfo(existing); - var resolved = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? existing; - - return remainder.Length == 0 ? resolved : Path.GetFullPath(Path.Combine(resolved, remainder)); - } - catch (IOException) - { - return fullPath; - } - catch (UnauthorizedAccessException) - { - return fullPath; - } - catch (NotSupportedException) - { - return fullPath; + return Path.GetFullPath(current); } - catch (ArgumentException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException or SecurityException) { return fullPath; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index a329cf67f..331c16bb9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -114,6 +114,40 @@ public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink() } } + /// + /// Rejects a candidate that leaves the base directory through an intermediate symbolic link + /// when the target file on the outside destination already exists on disk. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink_WhenOutsideTargetFileExists() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + var outsideFile = Path.Combine(outside, "installer.exe"); + File.WriteAllText(outsideFile, "payload"); + + if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), outside)) + { + return; + } + + var candidate = Path.Combine(baseDirectory, "link", "installer.exe"); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + /// /// Accepts a candidate beneath a symbolic link that stays inside the base directory, so /// following links tightens the check without refusing content a link merely reorganizes. diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs index f12d25577..79ae5fcde 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs @@ -37,8 +37,6 @@ public class CommunityOutpostProvider( ILogger logger) : BaseContentProvider(contentValidator, installationInstructionsService, logger) { - private readonly IProviderDefinitionLoader _providerDefinitionLoader = providerDefinitionLoader; - private readonly IContentDiscoverer _discoverer = discoverers.FirstOrDefault(d => d.SourceName.Contains(CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("No Community Outpost discoverer found"); @@ -129,7 +127,7 @@ public override async Task> GetValidatedContent } // Try to get from the loader (it should already be loaded at startup) - _cachedProviderDefinition = _providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId); + _cachedProviderDefinition = providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId); if (_cachedProviderDefinition == null) { diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index 6210e1d7f..b925a3b7b 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -28,30 +28,10 @@ public class SuperHackersProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, - IInstallationInstructionsService? installationInstructionsService, - ILogger logger) + ILogger logger, + IInstallationInstructionsService? installationInstructionsService = null) : BaseContentProvider(contentValidator, installationInstructionsService, logger) { - /// - /// Initializes a new instance of the class without an installation instructions service. - /// - /// The provider definition loader. - /// The GitHub API client. - /// The content resolvers. - /// The content deliverers. - /// The content validator. - /// The logger. - public SuperHackersProvider( - IProviderDefinitionLoader providerDefinitionLoader, - IGitHubApiClient gitHubApiClient, - IEnumerable resolvers, - IEnumerable deliverers, - IContentValidator contentValidator, - ILogger logger) - : this(providerDefinitionLoader, gitHubApiClient, resolvers, deliverers, contentValidator, null, logger) - { - } - private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(SuperHackersConstants.ResolverId, StringComparison.OrdinalIgnoreCase) == true) ?? throw new InvalidOperationException("No GitHub resolver found for SuperHackers"); From c0ed8d7ce66a142cb8adb581d1571424156fb25d Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 17:31:07 +0000 Subject: [PATCH 17/26] fix(core): recursively resolve symlink target paths in PathHelper.FollowLinks --- GenHub/GenHub.Core/Helpers/PathHelper.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index 1d0106afc..9f22a417d 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -123,8 +123,13 @@ private static bool IsContained(string normalizedRoot, string normalizedTarget) !Path.IsPathRooted(relative); } - private static string FollowLinks(string fullPath) + private static string FollowLinks(string fullPath, int maxDepth = 32) { + if (maxDepth <= 0) + { + return fullPath; + } + try { var normalized = Path.GetFullPath(fullPath); @@ -158,7 +163,7 @@ private static string FollowLinks(string fullPath) var target = info.ResolveLinkTarget(returnFinalTarget: true); if (target != null) { - current = target.FullName; + current = FollowLinks(target.FullName, maxDepth - 1); } } } From 196833bdc4c263e7c719bca88dfc95756c385a94 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 17:47:30 +0000 Subject: [PATCH 18/26] fix(core): resolve DeepSource exception filters, cyclomatic complexity, and ternary assignments --- GenHub/GenHub.Core/Helpers/PathHelper.cs | 36 ++- .../ContentDeliverers/HttpContentDeliverer.cs | 264 ++++++++++-------- .../Manifest/ContentManifestBuilder.cs | 22 +- 3 files changed, 190 insertions(+), 132 deletions(-) diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index 9f22a417d..8b59e6eff 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -43,7 +43,23 @@ public static bool AreSamePath(string first, string second) Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)), PathComparison); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + catch (IOException) + { + return string.Equals(first, second, PathComparison); + } + catch (UnauthorizedAccessException) + { + return string.Equals(first, second, PathComparison); + } + catch (SecurityException) + { + return string.Equals(first, second, PathComparison); + } + catch (NotSupportedException) + { + return string.Equals(first, second, PathComparison); + } + catch (ArgumentException) { return string.Equals(first, second, PathComparison); } @@ -170,7 +186,23 @@ private static string FollowLinks(string fullPath, int maxDepth = 32) return Path.GetFullPath(current); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException or SecurityException) + catch (IOException) + { + return fullPath; + } + catch (UnauthorizedAccessException) + { + return fullPath; + } + catch (SecurityException) + { + return fullPath; + } + catch (NotSupportedException) + { + return fullPath; + } + catch (ArgumentException) { return fullPath; } diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs index d16761c22..5b9364b95 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -19,12 +20,11 @@ namespace GenHub.Features.Content.Services.ContentDeliverers; /// Delivers remote HTTP content. /// Pure delivery - downloads and extracts content. /// -public class HttpContentDeliverer(IDownloadService downloadService, IContentManifestBuilder manifestBuilder, ILogger logger) : IContentDeliverer +public class HttpContentDeliverer( + IDownloadService downloadService, + IContentManifestBuilder manifestBuilder, + ILogger logger) : IContentDeliverer { - private readonly IDownloadService _downloadService = downloadService; - private readonly IContentManifestBuilder _manifestBuilder = manifestBuilder; - private readonly ILogger _logger = logger; - /// public string SourceName => ContentSourceNames.HttpDeliverer; @@ -56,121 +56,25 @@ public async Task> DeliverContentAsync( { try { - // Extract publisher from the manifest ID (3rd segment) - var idSegments = packageManifest.Id.Value.Split('.'); - var publisherId = idSegments.Length >= 3 ? idSegments[2] : "unknown"; - - var manifestVersionInt = int.TryParse(packageManifest.Version, out var parsedVersion) ? parsedVersion : 0; - var deliveredManifest = _manifestBuilder - .WithBasicInfo(publisherId, packageManifest.Name, manifestVersionInt) - .WithContentType(packageManifest.ContentType, packageManifest.TargetGame) - .WithPublisher( - packageManifest.Publisher?.Name ?? string.Empty, - packageManifest.Publisher?.Website ?? string.Empty, - packageManifest.Publisher?.SupportUrl ?? string.Empty, - packageManifest.Publisher?.ContactEmail ?? string.Empty, - packageManifest.Publisher?.PublisherType ?? string.Empty) - .WithMetadata( - packageManifest.Metadata?.Description ?? string.Empty, - packageManifest.Metadata?.Tags, - packageManifest.Metadata?.IconUrl ?? string.Empty, - packageManifest.Metadata?.ScreenshotUrls, - packageManifest.Metadata?.ChangelogUrl ?? string.Empty); - - // Add dependencies - foreach (var dep in packageManifest.Dependencies) - { - deliveredManifest.AddDependency( - dep.Id, - dep.Name, - dep.DependencyType, - dep.InstallBehavior, - dep.MinVersion ?? string.Empty, - dep.MaxVersion ?? string.Empty, - dep.CompatibleVersions, - dep.IsExclusive, - dep.ConflictsWith); - } + var deliveredManifest = InitializeManifestBuilder(packageManifest); var filesToDownload = packageManifest.Files.Where(f => !string.IsNullOrEmpty(f.DownloadUrl)).ToList(); - var totalFiles = filesToDownload.Count; - var processedFiles = 0; - - // Download and add files - foreach (var file in filesToDownload) + var downloadResult = await DownloadDeliveredFilesAsync( + deliveredManifest, + filesToDownload, + targetDirectory, + progress, + cancellationToken); + + if (!downloadResult.Success) { - cancellationToken.ThrowIfCancellationRequested(); - - var localPath = Path.Combine(targetDirectory, file.RelativePath); - - // Ensure directory exists - var directory = Path.GetDirectoryName(localPath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - // Report progress - progress?.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.Downloading, - ProgressPercentage = (double)processedFiles / totalFiles * 100, - CurrentOperation = $"Downloading {file.RelativePath}", - CurrentFile = file.RelativePath, - FilesProcessed = processedFiles, - TotalFiles = totalFiles, - }); - - // Download the file - var downloadResult = await _downloadService.DownloadFileAsync( - new Uri(file.DownloadUrl!), localPath, file.Hash, null, cancellationToken); - - if (!downloadResult.Success) - { - return OperationResult.CreateFailure( - $"Failed to download {file.RelativePath}: {downloadResult.FirstError}"); - } - - // Add the delivered file preserving InstallTarget, DownloadUrl, and hash - var fileInfo = new FileInfo(localPath); - var deliveredFile = new ManifestFile - { - RelativePath = file.RelativePath, - SourceType = ContentSourceType.ContentAddressable, - InstallTarget = file.InstallTarget, - IsExecutable = file.IsExecutable, - Hash = !string.IsNullOrEmpty(file.Hash) ? file.Hash : string.Empty, - DownloadUrl = file.DownloadUrl, - Size = fileInfo.Exists ? fileInfo.Length : file.Size, - Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, - }; - deliveredManifest.AddFile(deliveredFile); - - processedFiles++; + return OperationResult.CreateFailure(downloadResult.Errors); } - // Add any other files (without DownloadUrl) preserving metadata - foreach (var file in packageManifest.Files.Where(f => string.IsNullOrEmpty(f.DownloadUrl))) - { - var otherFile = new ManifestFile - { - RelativePath = file.RelativePath, - SourcePath = file.SourcePath ?? string.Empty, - SourceType = ContentSourceType.ContentAddressable, - InstallTarget = file.InstallTarget, - IsExecutable = file.IsExecutable, - Hash = file.Hash, - DownloadUrl = file.DownloadUrl, - Size = file.Size, - Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, - }; - deliveredManifest.AddFile(otherFile); - } + AddNonDownloadFiles(deliveredManifest, packageManifest.Files.Where(f => string.IsNullOrEmpty(f.DownloadUrl))); - // Add required directories deliveredManifest.AddRequiredDirectories([.. packageManifest.RequiredDirectories]); - // Add installation instructions if present if (packageManifest.InstallationInstructions != null) { deliveredManifest.WithInstallationInstructions(packageManifest.InstallationInstructions); @@ -178,9 +82,13 @@ public async Task> DeliverContentAsync( return OperationResult.CreateSuccess(deliveredManifest.Build()); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { - _logger.LogError(ex, "Failed to deliver HTTP content for manifest {ManifestId}", packageManifest.Id); + logger.LogError(ex, "Failed to deliver HTTP content for manifest {ManifestId}", packageManifest.Id); return OperationResult.CreateFailure($"Content delivery failed: {ex.Message}"); } } @@ -205,8 +113,136 @@ public Task> ValidateContentAsync( } catch (Exception ex) { - _logger.LogError(ex, "Validation failed for HTTP content manifest {ManifestId}", manifest.Id); + logger.LogError(ex, "Validation failed for HTTP content manifest {ManifestId}", manifest.Id); return Task.FromResult(OperationResult.CreateFailure($"Validation failed: {ex.Message}")); } } + + private static void AddDeliveredFile( + IContentManifestBuilder deliveredManifest, + ManifestFile file, + string localPath) + { + var fileInfo = new FileInfo(localPath); + var deliveredFile = new ManifestFile + { + RelativePath = file.RelativePath, + SourceType = ContentSourceType.ContentAddressable, + InstallTarget = file.InstallTarget, + IsExecutable = file.IsExecutable, + Hash = !string.IsNullOrEmpty(file.Hash) ? file.Hash : string.Empty, + DownloadUrl = file.DownloadUrl, + Size = fileInfo.Exists ? fileInfo.Length : file.Size, + Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, + }; + deliveredManifest.AddFile(deliveredFile); + } + + private static void AddNonDownloadFiles( + IContentManifestBuilder deliveredManifest, + IEnumerable files) + { + foreach (var file in files) + { + var otherFile = new ManifestFile + { + RelativePath = file.RelativePath, + SourcePath = file.SourcePath ?? string.Empty, + SourceType = ContentSourceType.ContentAddressable, + InstallTarget = file.InstallTarget, + IsExecutable = file.IsExecutable, + Hash = file.Hash, + DownloadUrl = file.DownloadUrl, + Size = file.Size, + Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, + }; + deliveredManifest.AddFile(otherFile); + } + } + + private IContentManifestBuilder InitializeManifestBuilder(ContentManifest packageManifest) + { + var idSegments = packageManifest.Id.Value.Split('.'); + var publisherId = idSegments.Length >= 3 ? idSegments[2] : "unknown"; + var manifestVersionInt = int.TryParse(packageManifest.Version, out var parsedVersion) ? parsedVersion : 0; + + var builder = manifestBuilder + .WithBasicInfo(publisherId, packageManifest.Name, manifestVersionInt) + .WithContentType(packageManifest.ContentType, packageManifest.TargetGame) + .WithPublisher( + packageManifest.Publisher?.Name ?? string.Empty, + packageManifest.Publisher?.Website ?? string.Empty, + packageManifest.Publisher?.SupportUrl ?? string.Empty, + packageManifest.Publisher?.ContactEmail ?? string.Empty, + packageManifest.Publisher?.PublisherType ?? string.Empty) + .WithMetadata( + packageManifest.Metadata?.Description ?? string.Empty, + packageManifest.Metadata?.Tags, + packageManifest.Metadata?.IconUrl ?? string.Empty, + packageManifest.Metadata?.ScreenshotUrls, + packageManifest.Metadata?.ChangelogUrl ?? string.Empty); + + foreach (var dep in packageManifest.Dependencies) + { + builder.AddDependency( + dep.Id, + dep.Name, + dep.DependencyType, + dep.InstallBehavior, + dep.MinVersion ?? string.Empty, + dep.MaxVersion ?? string.Empty, + dep.CompatibleVersions, + dep.IsExclusive, + dep.ConflictsWith); + } + + return builder; + } + + private async Task> DownloadDeliveredFilesAsync( + IContentManifestBuilder deliveredManifest, + IReadOnlyList filesToDownload, + string targetDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + var totalFiles = filesToDownload.Count; + var processedFiles = 0; + + foreach (var file in filesToDownload) + { + cancellationToken.ThrowIfCancellationRequested(); + + var localPath = Path.Combine(targetDirectory, file.RelativePath); + var directory = Path.GetDirectoryName(localPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Downloading, + ProgressPercentage = totalFiles > 0 ? (double)processedFiles / totalFiles * 100 : 100, + CurrentOperation = $"Downloading {file.RelativePath}", + CurrentFile = file.RelativePath, + FilesProcessed = processedFiles, + TotalFiles = totalFiles, + }); + + var downloadResult = await downloadService.DownloadFileAsync( + new Uri(file.DownloadUrl!), localPath, file.Hash, null, cancellationToken); + + if (!downloadResult.Success) + { + return OperationResult.CreateFailure( + $"Failed to download {file.RelativePath}: {downloadResult.FirstError}"); + } + + AddDeliveredFile(deliveredManifest, file, localPath); + processedFiles++; + } + + return OperationResult.CreateSuccess(true); + } } diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index a55b8603d..a0328d9ce 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -615,19 +615,14 @@ public IContentManifestBuilder AddRequiredDirectories(params string[] directorie public IContentManifestBuilder WithInstallationInstructions( WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy) { - if (_manifest.InstallationInstructions == null) - { - _manifest.InstallationInstructions = new InstallationInstructions { WorkspaceStrategy = workspaceStrategy }; - } - else - { - _manifest.InstallationInstructions = new InstallationInstructions + _manifest.InstallationInstructions = _manifest.InstallationInstructions == null + ? new InstallationInstructions { WorkspaceStrategy = workspaceStrategy } + : new InstallationInstructions { WorkspaceStrategy = workspaceStrategy, DownloadHash = _manifest.InstallationInstructions.DownloadHash, PostInstallSteps = [.. _manifest.InstallationInstructions.PostInstallSteps], }; - } logger.LogDebug("Set workspace strategy: {Strategy}", workspaceStrategy); return this; @@ -636,19 +631,14 @@ public IContentManifestBuilder WithInstallationInstructions( /// public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions) { - if (installationInstructions == null) - { - _manifest.InstallationInstructions = new InstallationInstructions(); - } - else - { - _manifest.InstallationInstructions = new InstallationInstructions + _manifest.InstallationInstructions = installationInstructions == null + ? new InstallationInstructions() + : new InstallationInstructions { WorkspaceStrategy = installationInstructions.WorkspaceStrategy, DownloadHash = installationInstructions.DownloadHash, PostInstallSteps = [.. installationInstructions.PostInstallSteps], }; - } logger.LogDebug( "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps", From 539edae3c3f7da69ae893baa88f8f324eff0e1d4 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 18:01:31 +0000 Subject: [PATCH 19/26] fix(content): add Windows platform guard to GeneralsOnlineProvider.PrepareContentInternalAsync --- .../GeneralsOnline/GeneralsOnlineProvider.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index a0a081018..bce64e78a 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -204,6 +204,12 @@ protected override async Task> PrepareContentIn IProgress? progress, CancellationToken cancellationToken) { + if (!OperatingSystem.IsWindows()) + { + return OperationResult.CreateFailure( + "GeneralsOnline is currently supported only on Windows. Easy Anti-Cheat was not designed for Wine/Proton environments."); + } + Logger.LogInformation("Preparing Generals Online content: {Version}", manifest.Version); try @@ -268,7 +274,13 @@ protected override async Task RollbackPreparedContentAsync( try { - _preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out var preExistingIds); + if (!_preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out var preExistingIds) || preExistingIds == null) + { + Logger.LogWarning( + "No pre-delivery manifest snapshot found for {ManifestId}; skipping rollback manifest unregistration to avoid removing existing content", + originalManifest.Id); + return; + } var allManifestsResult = await manifestPool.GetAllManifestsAsync(cancellationToken); if (allManifestsResult.Success && allManifestsResult.Data != null) @@ -276,7 +288,7 @@ protected override async Task RollbackPreparedContentAsync( var matchingManifests = allManifestsResult.Data .Where(m => string.Equals(m.Version, preparedManifest.Version, StringComparison.OrdinalIgnoreCase) && string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase) && - (preExistingIds == null || !preExistingIds.Contains(m.Id))) + !preExistingIds.Contains(m.Id)) .ToList(); foreach (var manifest in matchingManifests) From 3c5846ef7f236eb77e30f7a59d10311134f2f143 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 10:11:51 +0000 Subject: [PATCH 20/26] fix(review): address review feedback, containment tests, and DI registration --- .../Constants/PublisherTypeConstants.cs | 1 - .../IInstallationInstructionsService.cs | 20 +--- .../GenHub.Core/Models/Common/UserSettings.cs | 3 +- .../Content/BaseContentProviderTests.cs | 5 +- .../Content/GitHubContentProviderTests.cs | 1 + .../InstallationInstructionsServiceTests.cs | 91 ++++++++++++++++++- .../Publishers/SuperHackersProviderTests.cs | 3 +- .../GameProfileLauncherViewModelTests.cs | 3 +- .../ContentProviders/BaseContentProvider.cs | 32 ++++++- .../EasyAntiCheatPrecondition.cs | 5 +- .../GeneralsOnlineManifestFactory.cs | 6 +- .../GeneralsOnline/GeneralsOnlineProvider.cs | 11 +++ .../InstallationInstructionsService.cs | 15 +-- .../Publishers/SuperHackersProvider.cs | 2 +- .../Manifest/ContentManifestBuilder.cs | 22 +++-- .../ContentPipelineModule.cs | 6 +- 16 files changed, 166 insertions(+), 60 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs index f6519b372..5ca6cabfb 100644 --- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs @@ -72,7 +72,6 @@ public static class PublisherTypeConstants GeneralsOnline, CommunityOutpost, TheSuperHackers, - GenHubInternal, }; /// diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs index 6a64a3d64..74f9cd2a3 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -12,22 +12,6 @@ namespace GenHub.Core.Interfaces.Content; /// public interface IInstallationInstructionsService { - /// - /// Executes post-installation steps for the specified manifest. - /// - /// The content manifest declaring post-installation steps. - /// The working directory containing the content files. - /// The provider source name supplying the content, used for step authorization. - /// Optional progress reporter for acquisition status. - /// A token to cancel the operation. - /// A result indicating whether all post-installation steps succeeded. - Task ExecutePostInstallStepsAsync( - ContentManifest manifest, - string workingDirectory, - string? providerSource = null, - IProgress? progress = null, - CancellationToken cancellationToken = default); - /// /// Executes post-installation steps for the specified manifest, optionally forcing run-once steps. /// @@ -41,8 +25,8 @@ Task ExecutePostInstallStepsAsync( Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, - string? providerSource, - bool force, + string? providerSource = null, + bool force = false, IProgress? progress = null, CancellationToken cancellationToken = default); } diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index a2ed56da0..ebcff9744 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -114,7 +114,7 @@ public void MarkAsExplicitlySet(string propertyName) /// if already executed; otherwise, . public bool IsInstallationStepExecuted(string stepKey) { - return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps.Contains(stepKey); + return !string.IsNullOrWhiteSpace(stepKey) && (ExecutedInstallationSteps?.Contains(stepKey) ?? false); } /// @@ -125,6 +125,7 @@ public void RecordInstallationStepExecuted(string stepKey) { if (!string.IsNullOrWhiteSpace(stepKey)) { + ExecutedInstallationSteps ??= []; ExecutedInstallationSteps.Add(stepKey); } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index dc6903dca..3be9893e8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -52,6 +52,7 @@ public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallSte It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(OperationResult.CreateSuccess()); @@ -70,7 +71,7 @@ public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallSte // Assert Assert.True(result.Success); validatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once); - instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", "Test Provider", It.IsAny>(), It.IsAny()), Times.Once); + instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", "Test Provider", false, It.IsAny>(), It.IsAny()), Times.Once); validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once); } @@ -99,6 +100,7 @@ public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync() It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(OperationResult.CreateFailure("Post-install step execution error")); @@ -145,6 +147,7 @@ public async Task PrepareContentAsync_CancelsAndTriggersRollbackAsync() It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(new OperationCanceledException()); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index 32657ee6a..8ecfe2931 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -51,6 +51,7 @@ public GitHubContentProviderTests() It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(OperationResult.CreateSuccess()); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index a68a9de44..6deb8c36b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -626,18 +626,102 @@ public async Task ExecutePostInstallStepsAsync_ElevationOnNonWindows_ReturnsFail } /// - /// Verifies that caller cancellation terminates the running child installer process. + /// Verifies that remove file steps reject paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RemoveFile_PathTraversalTarget_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Remove Escape", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = "../../outside.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that rename file steps reject source paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_SourcePathTraversal_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename Source Escape", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "../../outside.tmp", + DestinationRelativePath = "dest.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that rename file steps reject destination paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_DestinationPathTraversal_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename Destination Escape", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "source.tmp", + DestinationRelativePath = "../../outside.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that cancellation token terminates the running process and throws OperationCanceledException. /// /// A task representing the asynchronous unit test. [Fact] public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProcessAndThrows() { - var scriptName = OperatingSystem.IsWindows() ? "sleep_installer.bat" : "sleep_installer.sh"; + var scriptName = OperatingSystem.IsWindows() ? "sleep_installer.exe" : "sleep_installer.sh"; var fullPath = Path.Combine(_tempDirectory, scriptName); if (OperatingSystem.IsWindows()) { - File.WriteAllText(fullPath, "@echo off\r\nping -n 30 127.0.0.1 > nul\r\n"); + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); } else { @@ -668,6 +752,7 @@ public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProc Name = "Long Running Step", Kind = InstallationStepKind.RunVerifiedInstaller, TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "ping", "-n", "30", "127.0.0.1"] : [], }, ], }; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs index 2b645c6e5..6e6a8a938 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs @@ -55,7 +55,8 @@ public SuperHackersProviderTests() [_resolverMock.Object], [_delivererMock.Object], _validatorMock.Object, - NullLogger.Instance); + NullLogger.Instance, + new Mock().Object); } /// 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 92737c49d..453bf4be1 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 @@ -337,7 +337,8 @@ private static SuperHackersProvider CreateSuperHackersProvider() [resolverMock.Object], [delivererMock.Object], new Mock().Object, - NullLogger.Instance); + NullLogger.Instance, + new Mock().Object); } /// diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 6e0a74262..89948bacf 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -172,7 +172,18 @@ public virtual async Task> PrepareContentAsync( if (result.Success && result.Data != null) { - if (_installationInstructionsService != null) + if (_installationInstructionsService == null) + { + if (result.Data.InstallationInstructions?.PostInstallSteps?.Count > 0) + { + Logger.LogWarning( + "Manifest {ManifestId} declares {Count} post-installation step(s), but {ProviderName} has no installation instructions service; the steps were not executed", + manifest.Id, + result.Data.InstallationInstructions.PostInstallSteps.Count, + SourceName); + } + } + else { try { @@ -240,6 +251,8 @@ public virtual async Task> PrepareContentAsync( { Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); } + + await OnContentPreparationCompletedAsync(manifest, result.Data, workingDirectory, cancellationToken); } return result; @@ -273,6 +286,23 @@ protected virtual Task RollbackPreparedContentAsync( return Task.CompletedTask; } + /// + /// Executes cleanup or finalization when content preparation and validation succeed. + /// + /// The original requested manifest. + /// The prepared manifest returned by PrepareContentInternalAsync. + /// The working directory where content was prepared. + /// A token to cancel finalization operations. + /// A task representing the asynchronous operation. + protected virtual Task OnContentPreparationCompletedAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + /// /// Gets the logger for this provider. /// diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs index 9415a242b..0267df184 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -57,10 +57,7 @@ private static bool IsProductRegisteredOnWindows(InstallationStep step) { try { - var productId = step.Arguments is { Count: > 1 } - ? step.Arguments[1] - : GeneralsOnlineConstants.EacProductId; - + var productId = GeneralsOnlineConstants.EacProductId; if (string.IsNullOrWhiteSpace(productId)) { return false; diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index f8b92ec41..4f7ebcc5a 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -721,10 +721,10 @@ private InstallationInstructions BuildInstallationInstructions( IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)); var inheritedPostSteps = (manifest.InstallationInstructions?.PostInstallSteps ?? []) - .Where(s => hasEacSetup || !string.Equals( + .Where(s => s != null && (hasEacSetup || !string.Equals( s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, - StringComparison.OrdinalIgnoreCase)); + StringComparison.OrdinalIgnoreCase))); var instructions = new InstallationInstructions { @@ -735,7 +735,7 @@ private InstallationInstructions BuildInstallationInstructions( if (manifest.ContentType == ContentType.GameClient && hasEacSetup && - instructions.PostInstallSteps.All(s => !string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) + instructions.PostInstallSteps.All(s => s == null || !string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) { instructions.PostInstallSteps.Add(new InstallationStep { diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index bce64e78a..ca913725b 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -310,4 +310,15 @@ protected override async Task RollbackPreparedContentAsync( Logger.LogError(ex, "Error occurred during Generals Online manifest registration rollback"); } } + + /// + protected override Task OnContentPreparationCompletedAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + _preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out _); + return Task.CompletedTask; + } } diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 1768f7e05..e6783ed58 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -52,23 +52,12 @@ public InstallationInstructionsService( { } - /// - public Task ExecutePostInstallStepsAsync( - ContentManifest manifest, - string workingDirectory, - string? providerSource = null, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - return ExecutePostInstallStepsAsync(manifest, workingDirectory, providerSource, force: false, progress, cancellationToken); - } - /// public async Task ExecutePostInstallStepsAsync( ContentManifest manifest, string workingDirectory, - string? providerSource, - bool force, + string? providerSource = null, + bool force = false, IProgress? progress = null, CancellationToken cancellationToken = default) { diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index b925a3b7b..d653e90f4 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -29,7 +29,7 @@ public class SuperHackersProvider( IEnumerable deliverers, IContentValidator contentValidator, ILogger logger, - IInstallationInstructionsService? installationInstructionsService = null) + IInstallationInstructionsService installationInstructionsService) : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r => diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index a0328d9ce..1e52a8be0 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -621,7 +621,9 @@ public IContentManifestBuilder WithInstallationInstructions( { WorkspaceStrategy = workspaceStrategy, DownloadHash = _manifest.InstallationInstructions.DownloadHash, - PostInstallSteps = [.. _manifest.InstallationInstructions.PostInstallSteps], + PostInstallSteps = _manifest.InstallationInstructions.PostInstallSteps == null + ? [] + : [.. _manifest.InstallationInstructions.PostInstallSteps], }; logger.LogDebug("Set workspace strategy: {Strategy}", workspaceStrategy); @@ -631,14 +633,16 @@ public IContentManifestBuilder WithInstallationInstructions( /// public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions) { - _manifest.InstallationInstructions = installationInstructions == null - ? new InstallationInstructions() - : new InstallationInstructions - { - WorkspaceStrategy = installationInstructions.WorkspaceStrategy, - DownloadHash = installationInstructions.DownloadHash, - PostInstallSteps = [.. installationInstructions.PostInstallSteps], - }; + ArgumentNullException.ThrowIfNull(installationInstructions); + + _manifest.InstallationInstructions = new InstallationInstructions + { + WorkspaceStrategy = installationInstructions.WorkspaceStrategy, + DownloadHash = installationInstructions.DownloadHash, + PostInstallSteps = installationInstructions.PostInstallSteps == null + ? [] + : [.. installationInstructions.PostInstallSteps], + }; logger.LogDebug( "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps", diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index ddf16ae44..322c7bbe2 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -240,9 +240,6 @@ private static void AddGeneralsOnlinePipeline(IServiceCollection services) services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(sp => sp.GetRequiredService()); - - // Register Easy Anti-Cheat installation step precondition - services.AddSingleton(); } /// @@ -365,6 +362,9 @@ private static void AddSharedComponents(IServiceCollection services) // Register content orchestrator and validator services.AddSingleton(); + // Register installation step preconditions + services.AddSingleton(); + // Register installation instructions execution service services.AddSingleton(); } From 9e6b5a3dcde62931ce20281d7dd78ed239286c03 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 11:07:15 +0000 Subject: [PATCH 21/26] fix(content): address review feedback across deliverers, providers, and instructions --- .../Constants/GeneralsOnlineConstants.cs | 2 +- .../Manifest/IContentManifestBuilder.cs | 14 ++ .../GenHub.Core/Models/Common/UserSettings.cs | 2 +- .../InstallationInstructionsServiceTests.cs | 143 ++++++++++++++ .../Publishers/SuperHackersProviderTests.cs | 13 +- .../Helpers/PathHelperTests.cs | 46 +++++ .../ContentDeliverers/HttpContentDeliverer.cs | 42 ++-- .../ContentProviders/BaseContentProvider.cs | 184 ++++++++++-------- .../EasyAntiCheatPrecondition.cs | 25 ++- .../GeneralsOnlineManifestFactory.cs | 32 +-- .../GeneralsOnline/GeneralsOnlineProvider.cs | 13 +- .../InstallationInstructionsService.cs | 96 +++++---- .../Manifest/ContentManifestBuilder.cs | 42 ++++ 13 files changed, 491 insertions(+), 163 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 306800947..bebc15802 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -140,7 +140,7 @@ public static class GeneralsOnlineConstants public const string EacStatusMessage = "Installing AntiCheat"; /// Unique step key identifying Easy Anti-Cheat installation for Generals Online. - public const string EacStepKey = "generalsonline:eac:fc1cc0d936424212b645105f084d08b0"; + public const string EacStepKey = PublisherType + ":eac:" + EacProductId; // ===== Content Tags ===== diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs index e69289608..58985d1e0 100644 --- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs +++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs @@ -86,6 +86,13 @@ public interface IContentManifestBuilder /// The builder instance for chaining. IContentManifestBuilder WithPublisher(string name, string website = "", string supportUrl = "", string contactEmail = "", string publisherType = ""); + /// + /// Sets publisher information from an existing instance. + /// + /// The publisher information. + /// The builder instance for chaining. + IContentManifestBuilder WithPublisher(PublisherInfo publisher); + /// /// Sets content metadata. /// @@ -260,6 +267,13 @@ IContentManifestBuilder AddContentReference( string minVersion = "", string maxVersion = ""); + /// + /// Sets content references for cross-publisher linking. + /// + /// The collection of content references. + /// The builder instance for chaining. + IContentManifestBuilder WithContentReferences(IEnumerable contentReferences); + /// /// Adds a file patching operation to the manifest. /// diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index ebcff9744..fd4c33fe7 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -114,7 +114,7 @@ public void MarkAsExplicitlySet(string propertyName) /// if already executed; otherwise, . public bool IsInstallationStepExecuted(string stepKey) { - return !string.IsNullOrWhiteSpace(stepKey) && (ExecutedInstallationSteps?.Contains(stepKey) ?? false); + return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps != null && ExecutedInstallationSteps.Contains(stepKey); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 6deb8c36b..6735c8aa1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Notifications; using GenHub.Core.Models.Common; using GenHub.Core.Models.Content; @@ -768,6 +769,148 @@ await Assert.ThrowsAnyAsync(() => cancellationToken: cts.Token)); } + /// + /// Verifies that when a precondition is fulfilled, execution is skipped and the step key is recorded. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_PreconditionFulfilled_SkipsExecutionAndRecordsStepKey() + { + var preconditionMock = new Mock(); + preconditionMock.Setup(p => p.CanHandle(It.IsAny(), It.IsAny())).Returns(true); + preconditionMock.Setup(p => p.IsAlreadyFulfilled(It.IsAny(), It.IsAny())).Returns(true); + + var serviceWithPrecondition = new InstallationInstructionsService( + _hashProviderMock.Object, + _notificationServiceMock.Object, + _userSettingsServiceMock.Object, + [preconditionMock.Object], + NullLogger.Instance); + + const string stepKey = "test:precondition:step"; + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Preconditioned Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "nonexistent.exe", + StepKey = stepKey, + RunOnce = true, + }, + ], + }; + + var result = await serviceWithPrecondition.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.True(_userSettings.IsInstallationStepExecuted(stepKey)); + } + + /// + /// Verifies that verification fails when a step target file has no declared hash in the manifest. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NoDeclaredHash_FailsVerification() + { + var scriptName = "installer_nohash.exe"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + File.WriteAllText(fullPath, "binary content"); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = string.Empty, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "No Hash Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("has no declared hash", result.FirstError); + } + + /// + /// Verifies that an installer process exiting with a non-zero exit code produces an execution failure. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NonZeroExitCode_FailsExecution() + { + var scriptName = OperatingSystem.IsWindows() ? "exit_error.cmd" : "exit_error.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + File.WriteAllText(fullPath, "exit /b 42\r\n"); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 42\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "exit_error_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Failing Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("failed with exit code", result.FirstError); + } + private static ContentManifest CreateBaseManifest() => new() { Id = "1.0.test.gameclient.variant", diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs index 6e6a8a938..ace7c1ac6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs @@ -30,6 +30,7 @@ public class SuperHackersProviderTests private readonly Mock _resolverMock; private readonly Mock _delivererMock; private readonly Mock _validatorMock; + private readonly Mock _instructionsServiceMock; private readonly SuperHackersProvider _provider; /// @@ -42,6 +43,7 @@ public SuperHackersProviderTests() _resolverMock = new Mock(); _delivererMock = new Mock(); _validatorMock = new Mock(); + _instructionsServiceMock = new Mock(); _resolverMock.Setup(r => r.ResolverId).Returns(SuperHackersConstants.ResolverId); _delivererMock.Setup(d => d.SourceName).Returns(ContentSourceNames.GitHubDeliverer); @@ -49,6 +51,15 @@ public SuperHackersProviderTests() _validatorMock.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new ValidationResult("test", [])); + _instructionsServiceMock.Setup(s => s.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _provider = new SuperHackersProvider( _providerDefinitionLoaderMock.Object, _gitHubApiClientMock.Object, @@ -56,7 +67,7 @@ public SuperHackersProviderTests() [_delivererMock.Object], _validatorMock.Object, NullLogger.Instance, - new Mock().Object); + _instructionsServiceMock.Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index 331c16bb9..a03930eb9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -178,6 +178,38 @@ public void IsPathWithinDirectory_AcceptsCandidateBehindASymbolicLinkThatStaysIn } } + /// + /// Rejects a candidate that is a direct file symbolic link pointing to a file outside the base directory. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateThatIsDirectFileSymbolicLink_PointingOutside() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + var outsideFile = Path.Combine(outside, "secret.dat"); + File.WriteAllText(outsideFile, "secret"); + + var linkFile = Path.Combine(baseDirectory, "link_file.dat"); + if (!TryCreateFileSymbolicLink(linkFile, outsideFile)) + { + return; + } + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, linkFile)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + /// /// Verifies that NormalizeRelativePath standardizes path separators. /// @@ -216,4 +248,18 @@ private static bool TryCreateDirectorySymbolicLink(string linkPath, string targe return false; } } + + private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + return false; + } + } } diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs index 5b9364b95..a4e81c5a8 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs @@ -130,6 +130,9 @@ private static void AddDeliveredFile( SourceType = ContentSourceType.ContentAddressable, InstallTarget = file.InstallTarget, IsExecutable = file.IsExecutable, + IsRequired = file.IsRequired, + PackageInfo = file.PackageInfo, + PatchSourceFile = file.PatchSourceFile, Hash = !string.IsNullOrEmpty(file.Hash) ? file.Hash : string.Empty, DownloadUrl = file.DownloadUrl, Size = fileInfo.Exists ? fileInfo.Length : file.Size, @@ -151,6 +154,9 @@ private static void AddNonDownloadFiles( SourceType = ContentSourceType.ContentAddressable, InstallTarget = file.InstallTarget, IsExecutable = file.IsExecutable, + IsRequired = file.IsRequired, + PackageInfo = file.PackageInfo, + PatchSourceFile = file.PatchSourceFile, Hash = file.Hash, DownloadUrl = file.DownloadUrl, Size = file.Size, @@ -168,19 +174,29 @@ private IContentManifestBuilder InitializeManifestBuilder(ContentManifest packag var builder = manifestBuilder .WithBasicInfo(publisherId, packageManifest.Name, manifestVersionInt) - .WithContentType(packageManifest.ContentType, packageManifest.TargetGame) - .WithPublisher( - packageManifest.Publisher?.Name ?? string.Empty, - packageManifest.Publisher?.Website ?? string.Empty, - packageManifest.Publisher?.SupportUrl ?? string.Empty, - packageManifest.Publisher?.ContactEmail ?? string.Empty, - packageManifest.Publisher?.PublisherType ?? string.Empty) - .WithMetadata( - packageManifest.Metadata?.Description ?? string.Empty, - packageManifest.Metadata?.Tags, - packageManifest.Metadata?.IconUrl ?? string.Empty, - packageManifest.Metadata?.ScreenshotUrls, - packageManifest.Metadata?.ChangelogUrl ?? string.Empty); + .WithContentType(packageManifest.ContentType, packageManifest.TargetGame); + + if (packageManifest.Publisher != null) + { + builder.WithPublisher(packageManifest.Publisher); + } + + builder.WithMetadata( + packageManifest.Metadata?.Description ?? string.Empty, + packageManifest.Metadata?.Tags, + packageManifest.Metadata?.IconUrl ?? string.Empty, + packageManifest.Metadata?.ScreenshotUrls, + packageManifest.Metadata?.ChangelogUrl ?? string.Empty); + + if (packageManifest.ContentReferences is { Count: > 0 }) + { + builder.WithContentReferences(packageManifest.ContentReferences); + } + + if (packageManifest.InstallationInstructions != null) + { + builder.WithInstallationInstructions(packageManifest.InstallationInstructions); + } foreach (var dep in packageManifest.Dependencies) { diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 89948bacf..be26d3495 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -21,18 +21,18 @@ namespace GenHub.Features.Content.Services.ContentProviders; public abstract class BaseContentProvider : IContentProvider { private readonly IContentValidator _contentValidator; - private readonly IInstallationInstructionsService? _installationInstructionsService; + private readonly IInstallationInstructionsService _installationInstructionsService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The content validator. - /// The optional installation instructions service. + /// The installation instructions service. /// The logger. protected BaseContentProvider( IContentValidator contentValidator, - IInstallationInstructionsService? installationInstructionsService, + IInstallationInstructionsService installationInstructionsService, ILogger logger) { _contentValidator = contentValidator; @@ -40,18 +40,6 @@ protected BaseContentProvider( _logger = logger; } - /// - /// Initializes a new instance of the class without an installation instructions service. - /// - /// The content validator. - /// The logger. - protected BaseContentProvider( - IContentValidator contentValidator, - ILogger logger) - : this(contentValidator, null, logger) - { - } - /// public abstract string SourceName { get; } @@ -170,90 +158,99 @@ public virtual async Task> PrepareContentAsync( // Delegate to implementation-specific preparation var result = await PrepareContentInternalAsync(manifest, workingDirectory, progress, cancellationToken); - if (result.Success && result.Data != null) + if (!result.Success) { - if (_installationInstructionsService == null) - { - if (result.Data.InstallationInstructions?.PostInstallSteps?.Count > 0) - { - Logger.LogWarning( - "Manifest {ManifestId} declares {Count} post-installation step(s), but {ProviderName} has no installation instructions service; the steps were not executed", - manifest.Id, - result.Data.InstallationInstructions.PostInstallSteps.Count, - SourceName); - } - } - else + return result; + } + + if (result.Data == null) + { + Logger.LogError("Content preparation returned success without manifest data for {ManifestId}", manifest.Id); + return OperationResult.CreateFailure($"Content preparation returned no manifest data for {manifest.Id}."); + } + + try + { + // Execute post-installation steps if declared on the delivered manifest + var stepExecutionResult = await _installationInstructionsService.ExecutePostInstallStepsAsync( + result.Data, + workingDirectory, + providerSource: SourceName, + progress: progress, + cancellationToken: cancellationToken); + + if (!stepExecutionResult.Success) { - try - { - // Execute post-installation steps if declared on the delivered manifest - var stepExecutionResult = await _installationInstructionsService.ExecutePostInstallStepsAsync( - result.Data, - workingDirectory, - providerSource: SourceName, - progress: progress, - cancellationToken: cancellationToken); - - if (!stepExecutionResult.Success) - { - Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); - await RollbackPreparedContentAsync(manifest, result.Data, workingDirectory, CancellationToken.None); - return OperationResult.CreateFailure(stepExecutionResult.Errors); - } - } - catch (OperationCanceledException) - { - Logger.LogInformation("Post-installation execution was canceled for manifest {ManifestId}; rolling back prepared content", manifest.Id); - await RollbackPreparedContentAsync(manifest, result.Data, workingDirectory, CancellationToken.None); - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "Unexpected error executing post-installation steps for manifest {ManifestId}; rolling back prepared content", manifest.Id); - await RollbackPreparedContentAsync(manifest, result.Data, workingDirectory, CancellationToken.None); - return OperationResult.CreateFailure($"Post-installation execution failed: {ex.Message}"); - } + Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure(stepExecutionResult.Errors); } + } + catch (OperationCanceledException) + { + Logger.LogInformation("Post-installation execution was canceled for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error executing post-installation steps for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure($"Post-installation execution failed: {ex.Message}"); + } - // Final validation of prepared content - progress?.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.ValidatingFiles, - CurrentOperation = "Validating prepared content...", - }); + // Final validation of prepared content + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.ValidatingFiles, + CurrentOperation = "Validating prepared content...", + }); - // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress - IProgress? validationProgress = null; - if (progress != null) + // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress + IProgress? validationProgress = null; + if (progress != null) + { + validationProgress = new Progress(vp => { - validationProgress = new Progress(vp => + // Map validation progress to content acquisition progress for UI display + progress.Report(new ContentAcquisitionProgress { - // Map validation progress to content acquisition progress for UI display - progress.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.ValidatingFiles, - ProgressPercentage = vp.PercentComplete, - CurrentOperation = vp.CurrentFile ?? "Validating files", - FilesProcessed = vp.Processed, - TotalFiles = vp.Total, - }); + Phase = ContentAcquisitionPhase.ValidatingFiles, + ProgressPercentage = vp.PercentComplete, + CurrentOperation = vp.CurrentFile ?? "Validating files", + FilesProcessed = vp.Processed, + TotalFiles = vp.Total, }); - } + }); + } - var fullResult = await ContentValidator.ValidateAllAsync( - workingDirectory, - result.Data, - validationProgress, - cancellationToken: cancellationToken); + var fullResult = await ContentValidator.ValidateAllAsync( + workingDirectory, + result.Data, + validationProgress, + cancellationToken: cancellationToken); - if (!fullResult.IsValid) - { - Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); - } + if (!fullResult.IsValid) + { + Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); + } + try + { await OnContentPreparationCompletedAsync(manifest, result.Data, workingDirectory, cancellationToken); } + catch (OperationCanceledException) + { + Logger.LogInformation("Content preparation completion hook was canceled for manifest {ManifestId}; rolling back", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Content preparation completion hook failed for manifest {ManifestId}; rolling back", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure($"Content preparation completion hook failed: {ex.Message}"); + } return result; } @@ -415,4 +412,19 @@ private ContentSearchResult CreateResolvedSearchResult(ContentSearchResult disco resolved.SetData(manifest); return resolved; } + + private async Task SafeRollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory) + { + try + { + await RollbackPreparedContentAsync(originalManifest, preparedManifest, workingDirectory, CancellationToken.None); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Rollback failed during error recovery for manifest {ManifestId}", originalManifest.Id); + } + } } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs index 0267df184..b524279db 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -57,22 +57,33 @@ private static bool IsProductRegisteredOnWindows(InstallationStep step) { try { - var productId = GeneralsOnlineConstants.EacProductId; + var productId = (step.Arguments != null && step.Arguments.Count > 1 && !string.IsNullOrWhiteSpace(step.Arguments[1])) + ? step.Arguments[1] + : GeneralsOnlineConstants.EacProductId; + if (string.IsNullOrWhiteSpace(productId)) { return false; } - using var key32 = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\WOW6432Node\EasyAntiCheat_EOS\{productId}"); - if (key32 != null) + var subKeyPath = $@"SOFTWARE\EasyAntiCheat_EOS\{productId}"; + + using (var baseKey32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32)) + using (var key32 = baseKey32.OpenSubKey(subKeyPath)) { - return true; + if (key32 != null) + { + return true; + } } - using var key64 = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\EasyAntiCheat_EOS\{productId}"); - if (key64 != null) + using (var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)) + using (var key64 = baseKey64.OpenSubKey(subKeyPath)) { - return true; + if (key64 != null) + { + return true; + } } } catch diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 4f7ebcc5a..10c87c14f 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -25,6 +25,21 @@ public class GeneralsOnlineManifestFactory( ILogger logger, IProviderDefinitionLoader providerLoader) : IPublisherManifestFactory { + /// + /// File info extracted from archive for manifest generation. + /// + /// The relative path within archive. + /// The file info. + /// The SHA-256 hash. + /// Whether this is a map file. + /// Whether this is a game data file. + private readonly record struct ExtractedFileInfo( + string RelativePath, + FileInfo FileInfo, + string Hash, + bool IsMap, + bool IsGameData); + /// public string PublisherId => PublisherTypeConstants.GeneralsOnline; @@ -526,21 +541,6 @@ private List CreateVariantManifestsFromOriginal(ContentManifest return manifests; } - /// - /// File info extracted from archive for manifest generation. - /// - /// The relative path within archive. - /// The file info. - /// The SHA-256 hash. - /// Whether this is a map file. - /// Whether this is a game data file. - private readonly record struct ExtractedFileInfo( - string RelativePath, - FileInfo FileInfo, - string Hash, - bool IsMap, - bool IsGameData); - /// /// Updates manifests (60Hz, QuickMatch MapPack, and GeneralsOnlineGameData data patch) with extracted file information. /// Computes SHA-256 hashes for all files for CAS integration. @@ -735,7 +735,7 @@ private InstallationInstructions BuildInstallationInstructions( if (manifest.ContentType == ContentType.GameClient && hasEacSetup && - instructions.PostInstallSteps.All(s => s == null || !string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase))) + instructions.PostInstallSteps.All(s => s == null || (!string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase) && !string.Equals(s.StepKey, GeneralsOnlineConstants.EacStepKey, StringComparison.OrdinalIgnoreCase)))) { instructions.PostInstallSteps.Add(new InstallationStep { diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index ca913725b..6a800a122 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -222,13 +222,16 @@ protected override async Task> PrepareContentIn } var existingPool = await manifestPool.GetAllManifestsAsync(cancellationToken); + if (!existingPool.Success || existingPool.Data == null) + { + return OperationResult.CreateFailure( + $"Failed to query existing manifests before delivery: {existingPool.FirstError}"); + } + var preExisting = new HashSet(StringComparer.OrdinalIgnoreCase); - if (existingPool.Success && existingPool.Data != null) + foreach (var m in existingPool.Data) { - foreach (var m in existingPool.Data) - { - preExisting.Add(m.Id); - } + preExisting.Add(m.Id); } _preExistingManifestIdsByManifest[manifest.Id] = preExisting; diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index e6783ed58..9d52cbfbc 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -35,6 +35,7 @@ public class InstallationInstructionsService( ILogger logger) : IInstallationInstructionsService { private static readonly TimeSpan InstallerStepTimeout = TimeSpan.FromMinutes(10); + private readonly SemaphoreSlim _executionGate = new(1, 1); /// /// Initializes a new instance of the class. @@ -100,46 +101,58 @@ private async Task ExecuteStepsAsync( return OperationResult.CreateFailure($"Working directory does not exist: '{workingDirectory}'"); } - var keysToRecord = new List(); - - for (var i = 0; i < steps.Count; i++) + await _executionGate.WaitAsync(cancellationToken); + try { - cancellationToken.ThrowIfCancellationRequested(); - var step = steps[i]; + var keysToRecord = new List(); - if (step == null) + for (var i = 0; i < steps.Count; i++) { - continue; - } + cancellationToken.ThrowIfCancellationRequested(); + var step = steps[i]; - var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, keysToRecord, cancellationToken); - if (!stepResult.Success) - { - return stepResult; - } - } - - if (userSettingsService != null && keysToRecord.Count > 0) - { - userSettingsService.Update(s => - { - foreach (var key in keysToRecord) + if (step == null) { - s.RecordInstallationStepExecuted(key); + continue; } - }); - try - { - await userSettingsService.SaveAsync(cancellationToken); + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, keysToRecord, cancellationToken); + if (!stepResult.Success) + { + return stepResult; + } } - catch (Exception ex) + + if (userSettingsService != null && keysToRecord.Count > 0) { - logger.LogWarning(ex, "Failed to persist executed installation step keys"); + userSettingsService.Update(s => + { + foreach (var key in keysToRecord) + { + s.RecordInstallationStepExecuted(key); + } + }); + + try + { + await userSettingsService.SaveAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist executed installation step keys"); + } } - } - return OperationResult.CreateSuccess(); + return OperationResult.CreateSuccess(); + } + finally + { + _executionGate.Release(); + } } private async Task ExecuteSingleStepAsync( @@ -170,7 +183,7 @@ private async Task ExecuteSingleStepAsync( progress?.Report(new ContentAcquisitionProgress { - Phase = ContentAcquisitionPhase.Extracting, + Phase = ContentAcquisitionPhase.Delivering, CurrentOperation = $"Skipping {step.Name} (already installed)", CurrentFile = step.TargetRelativePath ?? string.Empty, }); @@ -244,11 +257,12 @@ private string GetStepKey(InstallationStep step, ContentManifest manifest) } var publisher = manifest.Publisher?.PublisherType ?? "generic"; + var manifestId = manifest.Id.Value ?? string.Empty; var name = step.Name; var target = step.TargetRelativePath ?? string.Empty; var args = step.Arguments is { Count: > 0 } ? string.Join(" ", step.Arguments) : string.Empty; - return $"{publisher}:{name}:{target}:{args}".TrimEnd(':'); + return $"{publisher}:{manifestId}:{name}:{target}:{args}".TrimEnd(':'); } private async Task ExecuteRunVerifiedInstallerAsync( @@ -358,7 +372,21 @@ private async Task VerifyInstallerIntegrityAsync( $"Installer '{step.TargetRelativePath}' has no declared hash and cannot be verified."); } - var computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); + string computedHash; + try + { + computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to compute hash for installer '{Target}' in manifest {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure($"Failed to compute hash for installer '{step.TargetRelativePath}': {ex.Message}"); + } + if (!string.Equals(computedHash, manifestFile.Hash, StringComparison.OrdinalIgnoreCase)) { logger.LogError( @@ -389,7 +417,7 @@ private void NotifyStepStarting(InstallationStep step, IProgress RunInstallerProcessAsync( if (!process.HasExited) { process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); } } catch (Exception killEx) @@ -476,6 +505,7 @@ private async Task RunInstallerProcessAsync( if (!process.HasExited) { process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); } } catch (Exception killEx) diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index 1e52a8be0..828107c9c 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -255,6 +255,28 @@ public IContentManifestBuilder WithPublisher( return this; } + /// + public IContentManifestBuilder WithPublisher(PublisherInfo publisher) + { + ArgumentNullException.ThrowIfNull(publisher); + + _manifest.Publisher = new PublisherInfo + { + Name = publisher.Name, + PublisherType = publisher.PublisherType, + Website = publisher.Website, + SupportUrl = publisher.SupportUrl, + ContactEmail = publisher.ContactEmail, + UpdateApiEndpoint = publisher.UpdateApiEndpoint, + ContentIndexUrl = publisher.ContentIndexUrl, + UpdateCheckIntervalHours = publisher.UpdateCheckIntervalHours, + SupportsIncrementalUpdates = publisher.SupportsIncrementalUpdates, + AuthenticationMethod = publisher.AuthenticationMethod, + }; + logger.LogDebug("Set publisher: {PublisherName} (Type: {PublisherType})", publisher.Name, publisher.PublisherType); + return this; + } + /// /// Sets the metadata for the manifest. /// @@ -358,6 +380,16 @@ public IContentManifestBuilder AddContentReference( return this; } + /// + public IContentManifestBuilder WithContentReferences(IEnumerable contentReferences) + { + ArgumentNullException.ThrowIfNull(contentReferences); + + _manifest.ContentReferences = [.. contentReferences]; + logger.LogDebug("Set {Count} content references", _manifest.ContentReferences.Count); + return this; + } + /// /// Adds files from a directory to the manifest. /// @@ -682,6 +714,16 @@ public IContentManifestBuilder AddPostInstallStep( public IContentManifestBuilder AddPostInstallStep(InstallationStep step) { ArgumentNullException.ThrowIfNull(step); + if (step.Kind == InstallationStepKind.Unknown) + { + throw new ArgumentException("Installation step kind cannot be Unknown.", nameof(step)); + } + + if (string.IsNullOrWhiteSpace(step.Name)) + { + throw new ArgumentException("Installation step name cannot be empty or whitespace.", nameof(step)); + } + _manifest.InstallationInstructions ??= new InstallationInstructions(); _manifest.InstallationInstructions.PostInstallSteps.Add(step); logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce); From 9a57a3350cf384aa4d7e6b4e5945f8ee8b3fabdb Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 11:23:40 +0000 Subject: [PATCH 22/26] fix(core): address DeepSource static analysis findings in precondition and tests --- .../Helpers/PathHelperTests.cs | 10 ++++++++- .../EasyAntiCheatPrecondition.cs | 22 ++++++++----------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index a03930eb9..9bdd35e8b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -257,7 +257,15 @@ private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath return true; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (NotSupportedException) { return false; } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs index b524279db..b63a1b31e 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -57,7 +57,7 @@ private static bool IsProductRegisteredOnWindows(InstallationStep step) { try { - var productId = (step.Arguments != null && step.Arguments.Count > 1 && !string.IsNullOrWhiteSpace(step.Arguments[1])) + var productId = (step.Arguments is { Count: > 1 } && !string.IsNullOrWhiteSpace(step.Arguments[1])) ? step.Arguments[1] : GeneralsOnlineConstants.EacProductId; @@ -68,22 +68,18 @@ private static bool IsProductRegisteredOnWindows(InstallationStep step) var subKeyPath = $@"SOFTWARE\EasyAntiCheat_EOS\{productId}"; - using (var baseKey32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32)) - using (var key32 = baseKey32.OpenSubKey(subKeyPath)) + using var baseKey32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + using var key32 = baseKey32.OpenSubKey(subKeyPath); + if (key32 != null) { - if (key32 != null) - { - return true; - } + return true; } - using (var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)) - using (var key64 = baseKey64.OpenSubKey(subKeyPath)) + using var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var key64 = baseKey64.OpenSubKey(subKeyPath); + if (key64 != null) { - if (key64 != null) - { - return true; - } + return true; } } catch From f7d9e9f2369c4ff46818089fd136ef49765fb5e2 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 12:31:21 +0000 Subject: [PATCH 23/26] fix(core): initialize computedHash local variable to satisfy static analysis --- .../Content/Services/InstallationInstructionsService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 9d52cbfbc..27161ec3b 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -372,7 +372,7 @@ private async Task VerifyInstallerIntegrityAsync( $"Installer '{step.TargetRelativePath}' has no declared hash and cannot be verified."); } - string computedHash; + var computedHash = string.Empty; try { computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); From 09c385266d8bbda43356b2de0ccfbfbf9f5c71d2 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 13:49:23 +0000 Subject: [PATCH 24/26] fix(content): persist RunOnce keys immediately, check skip before authorization, and handle EAC registry exceptions --- .../InstallationInstructionsServiceTests.cs | 78 ++++++++++ .../EasyAntiCheatPreconditionTests.cs | 137 ++++++++++++++++++ .../EasyAntiCheatPrecondition.cs | 20 ++- .../InstallationInstructionsService.cs | 78 +++++----- 4 files changed, 269 insertions(+), 44 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs index 6735c8aa1..2eb524a35 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -911,6 +911,84 @@ public async Task ExecutePostInstallStepsAsync_NonZeroExitCode_FailsExecution() Assert.Contains("failed with exit code", result.FirstError); } + /// + /// Verifies that a successful RunOnce step persists its key immediately even if a subsequent step fails. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStep_PersistsKeyImmediatelyEvenIfLaterStepFails() + { + var successFile = "success.tmp"; + var fullPath = Path.Combine(_tempDirectory, successFile); + await File.WriteAllTextAsync(fullPath, "temporary"); + + const string step1Key = "step:runonce:first"; + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Step 1 Remove", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = successFile, + StepKey = step1Key, + RunOnce = true, + }, + new InstallationStep + { + Name = "Step 2 Unknown Kind", + Kind = InstallationStepKind.Unknown, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.False(File.Exists(fullPath)); + Assert.True(_userSettings.IsInstallationStepExecuted(step1Key)); + _userSettingsServiceMock.Verify(u => u.SaveAsync(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Verifies that an already-executed RunOnce step is skipped without failing provider authorization. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceAlreadyExecuted_DoesNotFailAuthorizationForUntrustedProvider() + { + const string stepKey = "step:untrusted:runonce"; + _userSettings.RecordInstallationStepExecuted(stepKey); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Already Executed Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "installer.exe", + StepKey = stepKey, + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: "untrusted_source"); + + Assert.True(result.Success); + } + private static ContentManifest CreateBaseManifest() => new() { Id = "1.0.test.gameclient.variant", diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs new file mode 100644 index 000000000..7f1913983 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Unit tests for . +/// +public sealed class EasyAntiCheatPreconditionTests +{ + private readonly EasyAntiCheatPrecondition _precondition = new(NullLogger.Instance); + + /// + /// Verifies that CanHandle returns false when step or manifest is null. + /// + [Fact] + public void CanHandle_NullStepOrManifest_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + Assert.False(_precondition.CanHandle(null!, manifest)); + Assert.False(_precondition.CanHandle(step, null!)); + } + + /// + /// Verifies that CanHandle returns false when step kind is not RunVerifiedInstaller. + /// + [Fact] + public void CanHandle_NonInstallerKind_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = new InstallationStep + { + Name = "Remove File Step", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }; + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that CanHandle returns false when publisher type is not GeneralsOnline. + /// + [Fact] + public void CanHandle_NonGeneralsOnlinePublisher_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + PublisherType = "OtherPublisher", + }; + + var step = CreateEacStep(); + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that CanHandle returns false when executable name does not match EAC setup executable. + /// + [Fact] + public void CanHandle_NonEacExecutable_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = new InstallationStep + { + Name = "Other Executable", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "other_installer.exe", + }; + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that IsAlreadyFulfilled returns false on non-Windows platforms. + /// + [Fact] + public void IsAlreadyFulfilled_NonWindows_ReturnsFalse() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + Assert.False(_precondition.IsAlreadyFulfilled(step, manifest)); + } + + /// + /// Verifies that CanHandle behavior matches operating system requirements. + /// + [Fact] + public void CanHandle_ValidStep_MatchesOperatingSystem() + { + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + var result = _precondition.CanHandle(step, manifest); + Assert.Equal(OperatingSystem.IsWindows(), result); + } + + private static ContentManifest CreateBaseManifest() => new() + { + Id = "1.0.test.gameclient.variant", + Name = "Generals Online", + Version = "1.0.0", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + }; + + private static InstallationStep CreateEacStep() => new() + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = ["install", GeneralsOnlineConstants.EacProductId], + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }; +} diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs index b63a1b31e..f538f8c05 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -1,10 +1,12 @@ using System; using System.IO; using System.Runtime.Versioning; +using System.Security; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; +using Microsoft.Extensions.Logging; using Microsoft.Win32; namespace GenHub.Features.Content.Services.GeneralsOnline; @@ -12,7 +14,8 @@ namespace GenHub.Features.Content.Services.GeneralsOnline; /// /// Precondition that checks whether Easy Anti-Cheat EOS product ID is already registered in the Windows registry. /// -public class EasyAntiCheatPrecondition : IInstallationStepPrecondition +/// Optional logger instance for diagnostics. +public class EasyAntiCheatPrecondition(ILogger? logger = null) : IInstallationStepPrecondition { /// public bool CanHandle(InstallationStep step, ContentManifest manifest) @@ -53,7 +56,7 @@ public bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest) } [SupportedOSPlatform("windows")] - private static bool IsProductRegisteredOnWindows(InstallationStep step) + private bool IsProductRegisteredOnWindows(InstallationStep step) { try { @@ -82,8 +85,19 @@ private static bool IsProductRegisteredOnWindows(InstallationStep step) return true; } } - catch + catch (SecurityException ex) { + logger?.LogWarning(ex, "Insufficient permissions to inspect Easy Anti-Cheat registry keys for step '{StepName}'", step.Name); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger?.LogWarning(ex, "Access denied when inspecting Easy Anti-Cheat registry keys for step '{StepName}'", step.Name); + return false; + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Error while checking Easy Anti-Cheat registry registration for step '{StepName}'", step.Name); return false; } diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs index 27161ec3b..bae5c14e2 100644 --- a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -104,8 +104,6 @@ private async Task ExecuteStepsAsync( await _executionGate.WaitAsync(cancellationToken); try { - var keysToRecord = new List(); - for (var i = 0; i < steps.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -116,37 +114,13 @@ private async Task ExecuteStepsAsync( continue; } - var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, keysToRecord, cancellationToken); + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, cancellationToken); if (!stepResult.Success) { return stepResult; } } - if (userSettingsService != null && keysToRecord.Count > 0) - { - userSettingsService.Update(s => - { - foreach (var key in keysToRecord) - { - s.RecordInstallationStepExecuted(key); - } - }); - - try - { - await userSettingsService.SaveAsync(cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to persist executed installation step keys"); - } - } - return OperationResult.CreateSuccess(); } finally @@ -162,18 +136,11 @@ private async Task ExecuteSingleStepAsync( string? providerSource, bool force, IProgress? progress, - List keysToRecord, CancellationToken cancellationToken) { - var authResult = ValidateProviderAuthorization(providerSource, manifest, step); - if (!authResult.Success) - { - return authResult; - } - var stepKey = GetStepKey(step, manifest); - if (!force && step.RunOnce && ShouldSkipStep(step, stepKey, manifest, keysToRecord)) + if (!force && step.RunOnce && await ShouldSkipStepAsync(step, stepKey, manifest, cancellationToken)) { logger.LogInformation( "Skipping installation step '{StepName}' for manifest {ManifestId} because it has already been executed (key: {StepKey})", @@ -191,6 +158,12 @@ private async Task ExecuteSingleStepAsync( return OperationResult.CreateSuccess(); } + var authResult = ValidateProviderAuthorization(providerSource, manifest, step); + if (!authResult.Success) + { + return authResult; + } + var result = OperationResult.CreateFailure("Uninitialized step result"); switch (step.Kind) { @@ -211,19 +184,19 @@ private async Task ExecuteSingleStepAsync( return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); } - if (result.Success && step.RunOnce && !string.IsNullOrWhiteSpace(stepKey) && !keysToRecord.Contains(stepKey)) + if (result.Success && step.RunOnce && !string.IsNullOrWhiteSpace(stepKey)) { - keysToRecord.Add(stepKey); + await RecordStepExecutedAsync(stepKey, cancellationToken); } return result; } - private bool ShouldSkipStep( + private async Task ShouldSkipStepAsync( InstallationStep step, string stepKey, ContentManifest manifest, - List keysToRecord) + CancellationToken cancellationToken) { if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true) { @@ -236,9 +209,9 @@ private bool ShouldSkipStep( { if (precondition.CanHandle(step, manifest) && precondition.IsAlreadyFulfilled(step, manifest)) { - if (!string.IsNullOrWhiteSpace(stepKey) && !keysToRecord.Contains(stepKey)) + if (!string.IsNullOrWhiteSpace(stepKey)) { - keysToRecord.Add(stepKey); + await RecordStepExecutedAsync(stepKey, cancellationToken); } return true; @@ -249,6 +222,29 @@ private bool ShouldSkipStep( return false; } + private async Task RecordStepExecutedAsync(string stepKey, CancellationToken cancellationToken) + { + if (userSettingsService == null || string.IsNullOrWhiteSpace(stepKey)) + { + return; + } + + userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); + + try + { + await userSettingsService.SaveAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist executed installation step key '{StepKey}'", stepKey); + } + } + private string GetStepKey(InstallationStep step, ContentManifest manifest) { if (!string.IsNullOrWhiteSpace(step.StepKey)) From 8b2f05945dc5ec3171d27b1d3d8c36db187abad7 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 14:08:19 +0000 Subject: [PATCH 25/26] revert(content): remove out-of-scope HttpContentDeliverer modifications --- .../ContentDeliverers/HttpContentDeliverer.cs | 265 +++++++----------- 1 file changed, 99 insertions(+), 166 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs index a4e81c5a8..af4ec4273 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -20,11 +19,12 @@ namespace GenHub.Features.Content.Services.ContentDeliverers; /// Delivers remote HTTP content. /// Pure delivery - downloads and extracts content. /// -public class HttpContentDeliverer( - IDownloadService downloadService, - IContentManifestBuilder manifestBuilder, - ILogger logger) : IContentDeliverer +public class HttpContentDeliverer(IDownloadService downloadService, IContentManifestBuilder manifestBuilder, ILogger logger) : IContentDeliverer { + private readonly IDownloadService _downloadService = downloadService; + private readonly IContentManifestBuilder _manifestBuilder = manifestBuilder; + private readonly ILogger _logger = logger; + /// public string SourceName => ContentSourceNames.HttpDeliverer; @@ -56,39 +56,116 @@ public async Task> DeliverContentAsync( { try { - var deliveredManifest = InitializeManifestBuilder(packageManifest); + // Extract publisher from the manifest ID (3rd segment) + var idSegments = packageManifest.Id.Value.Split('.'); + var publisherId = idSegments.Length >= 3 ? idSegments[2] : "unknown"; + + var manifestVersionInt = int.TryParse(packageManifest.Version, out var parsedVersion) ? parsedVersion : 0; + var deliveredManifest = _manifestBuilder + .WithBasicInfo(publisherId, packageManifest.Name, manifestVersionInt) + .WithContentType(packageManifest.ContentType, packageManifest.TargetGame) + .WithPublisher( + packageManifest.Publisher?.Name ?? string.Empty, + packageManifest.Publisher?.Website ?? string.Empty, + packageManifest.Publisher?.SupportUrl ?? string.Empty, + packageManifest.Publisher?.ContactEmail ?? string.Empty) + .WithMetadata( + packageManifest.Metadata?.Description ?? string.Empty, + packageManifest.Metadata?.Tags, + packageManifest.Metadata?.IconUrl ?? string.Empty, + packageManifest.Metadata?.ScreenshotUrls, + packageManifest.Metadata?.ChangelogUrl ?? string.Empty); + + // Add dependencies + foreach (var dep in packageManifest.Dependencies) + { + deliveredManifest.AddDependency( + dep.Id, + dep.Name, + dep.DependencyType, + dep.InstallBehavior, + dep.MinVersion ?? string.Empty, + dep.MaxVersion ?? string.Empty, + dep.CompatibleVersions, + dep.IsExclusive, + dep.ConflictsWith); + } var filesToDownload = packageManifest.Files.Where(f => !string.IsNullOrEmpty(f.DownloadUrl)).ToList(); - var downloadResult = await DownloadDeliveredFilesAsync( - deliveredManifest, - filesToDownload, - targetDirectory, - progress, - cancellationToken); + var totalFiles = filesToDownload.Count; + var processedFiles = 0; - if (!downloadResult.Success) + // Download and add files + foreach (var file in filesToDownload) { - return OperationResult.CreateFailure(downloadResult.Errors); + cancellationToken.ThrowIfCancellationRequested(); + + var localPath = Path.Combine(targetDirectory, file.RelativePath); + + // Ensure directory exists + var directory = Path.GetDirectoryName(localPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // Report progress + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Downloading, + ProgressPercentage = (double)processedFiles / totalFiles * 100, + CurrentOperation = $"Downloading {file.RelativePath}", + CurrentFile = file.RelativePath, + FilesProcessed = processedFiles, + TotalFiles = totalFiles, + }); + + // Download the file + var downloadResult = await _downloadService.DownloadFileAsync( + new Uri(file.DownloadUrl!), localPath, file.Hash, null, cancellationToken); + + if (!downloadResult.Success) + { + return OperationResult.CreateFailure( + $"Failed to download {file.RelativePath}: {downloadResult.FirstError}"); + } + + // Add the delivered file using the builder + await deliveredManifest.AddRemoteFileAsync( + file.RelativePath, + file.DownloadUrl ?? string.Empty, + ContentSourceType.ContentAddressable, + isExecutable: file.IsExecutable, + permissions: file.Permissions); + + processedFiles++; } - AddNonDownloadFiles(deliveredManifest, packageManifest.Files.Where(f => string.IsNullOrEmpty(f.DownloadUrl))); + // Add any other files (without DownloadUrl) as-is + foreach (var file in packageManifest.Files.Where(f => string.IsNullOrEmpty(f.DownloadUrl))) + { + await deliveredManifest.AddLocalFileAsync( + file.RelativePath, + file.SourcePath ?? string.Empty, + ContentSourceType.ContentAddressable, + isExecutable: file.IsExecutable, + permissions: file.Permissions); + } + // Add required directories deliveredManifest.AddRequiredDirectories([.. packageManifest.RequiredDirectories]); + // Add installation instructions if present if (packageManifest.InstallationInstructions != null) { - deliveredManifest.WithInstallationInstructions(packageManifest.InstallationInstructions); + deliveredManifest.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy); } return OperationResult.CreateSuccess(deliveredManifest.Build()); } - catch (OperationCanceledException) - { - throw; - } catch (Exception ex) { - logger.LogError(ex, "Failed to deliver HTTP content for manifest {ManifestId}", packageManifest.Id); + _logger.LogError(ex, "Failed to deliver HTTP content for manifest {ManifestId}", packageManifest.Id); return OperationResult.CreateFailure($"Content delivery failed: {ex.Message}"); } } @@ -113,152 +190,8 @@ public Task> ValidateContentAsync( } catch (Exception ex) { - logger.LogError(ex, "Validation failed for HTTP content manifest {ManifestId}", manifest.Id); + _logger.LogError(ex, "Validation failed for HTTP content manifest {ManifestId}", manifest.Id); return Task.FromResult(OperationResult.CreateFailure($"Validation failed: {ex.Message}")); } } - - private static void AddDeliveredFile( - IContentManifestBuilder deliveredManifest, - ManifestFile file, - string localPath) - { - var fileInfo = new FileInfo(localPath); - var deliveredFile = new ManifestFile - { - RelativePath = file.RelativePath, - SourceType = ContentSourceType.ContentAddressable, - InstallTarget = file.InstallTarget, - IsExecutable = file.IsExecutable, - IsRequired = file.IsRequired, - PackageInfo = file.PackageInfo, - PatchSourceFile = file.PatchSourceFile, - Hash = !string.IsNullOrEmpty(file.Hash) ? file.Hash : string.Empty, - DownloadUrl = file.DownloadUrl, - Size = fileInfo.Exists ? fileInfo.Length : file.Size, - Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, - }; - deliveredManifest.AddFile(deliveredFile); - } - - private static void AddNonDownloadFiles( - IContentManifestBuilder deliveredManifest, - IEnumerable files) - { - foreach (var file in files) - { - var otherFile = new ManifestFile - { - RelativePath = file.RelativePath, - SourcePath = file.SourcePath ?? string.Empty, - SourceType = ContentSourceType.ContentAddressable, - InstallTarget = file.InstallTarget, - IsExecutable = file.IsExecutable, - IsRequired = file.IsRequired, - PackageInfo = file.PackageInfo, - PatchSourceFile = file.PatchSourceFile, - Hash = file.Hash, - DownloadUrl = file.DownloadUrl, - Size = file.Size, - Permissions = file.Permissions ?? new FilePermissions { UnixPermissions = file.IsExecutable ? "755" : "644" }, - }; - deliveredManifest.AddFile(otherFile); - } - } - - private IContentManifestBuilder InitializeManifestBuilder(ContentManifest packageManifest) - { - var idSegments = packageManifest.Id.Value.Split('.'); - var publisherId = idSegments.Length >= 3 ? idSegments[2] : "unknown"; - var manifestVersionInt = int.TryParse(packageManifest.Version, out var parsedVersion) ? parsedVersion : 0; - - var builder = manifestBuilder - .WithBasicInfo(publisherId, packageManifest.Name, manifestVersionInt) - .WithContentType(packageManifest.ContentType, packageManifest.TargetGame); - - if (packageManifest.Publisher != null) - { - builder.WithPublisher(packageManifest.Publisher); - } - - builder.WithMetadata( - packageManifest.Metadata?.Description ?? string.Empty, - packageManifest.Metadata?.Tags, - packageManifest.Metadata?.IconUrl ?? string.Empty, - packageManifest.Metadata?.ScreenshotUrls, - packageManifest.Metadata?.ChangelogUrl ?? string.Empty); - - if (packageManifest.ContentReferences is { Count: > 0 }) - { - builder.WithContentReferences(packageManifest.ContentReferences); - } - - if (packageManifest.InstallationInstructions != null) - { - builder.WithInstallationInstructions(packageManifest.InstallationInstructions); - } - - foreach (var dep in packageManifest.Dependencies) - { - builder.AddDependency( - dep.Id, - dep.Name, - dep.DependencyType, - dep.InstallBehavior, - dep.MinVersion ?? string.Empty, - dep.MaxVersion ?? string.Empty, - dep.CompatibleVersions, - dep.IsExclusive, - dep.ConflictsWith); - } - - return builder; - } - - private async Task> DownloadDeliveredFilesAsync( - IContentManifestBuilder deliveredManifest, - IReadOnlyList filesToDownload, - string targetDirectory, - IProgress? progress, - CancellationToken cancellationToken) - { - var totalFiles = filesToDownload.Count; - var processedFiles = 0; - - foreach (var file in filesToDownload) - { - cancellationToken.ThrowIfCancellationRequested(); - - var localPath = Path.Combine(targetDirectory, file.RelativePath); - var directory = Path.GetDirectoryName(localPath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - progress?.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.Downloading, - ProgressPercentage = totalFiles > 0 ? (double)processedFiles / totalFiles * 100 : 100, - CurrentOperation = $"Downloading {file.RelativePath}", - CurrentFile = file.RelativePath, - FilesProcessed = processedFiles, - TotalFiles = totalFiles, - }); - - var downloadResult = await downloadService.DownloadFileAsync( - new Uri(file.DownloadUrl!), localPath, file.Hash, null, cancellationToken); - - if (!downloadResult.Success) - { - return OperationResult.CreateFailure( - $"Failed to download {file.RelativePath}: {downloadResult.FirstError}"); - } - - AddDeliveredFile(deliveredManifest, file, localPath); - processedFiles++; - } - - return OperationResult.CreateSuccess(true); - } } From c11d7d11f1390ee62aae5d8b61630917274d75fa Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 14:34:02 +0000 Subject: [PATCH 26/26] feat(generalsonline): propagate sha256 to manifest file and download verification --- .../GeneralsOnline/GeneralsOnlineRelease.cs | 6 ++ .../GeneralsOnlineDelivererTests.cs | 68 +++++++++++++++++++ .../GeneralsOnlineJsonCatalogParserTests.cs | 31 +++++++++ .../GeneralsOnlineManifestFactoryTests.cs | 29 ++++++++ .../GeneralsOnline/GeneralsOnlineDeliverer.cs | 13 +++- .../GeneralsOnlineJsonCatalogParser.cs | 1 + .../GeneralsOnlineManifestFactory.cs | 3 +- .../Infrastructure/GameProcessManager.cs | 2 +- 8 files changed, 149 insertions(+), 4 deletions(-) diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs index fd42ee2b2..b3a98df3a 100644 --- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs +++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs @@ -36,6 +36,12 @@ public class GeneralsOnlineRelease /// public long? PortableSize { get; init; } + /// + /// Gets SHA256 hash of the portable ZIP package for file verification. + /// Null when hash is unknown (e.g., from latest.txt API). + /// + public string? Sha256 { get; init; } + /// /// Gets release changelog/notes. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs index 4e5d7c096..ec5ff6a19 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs @@ -561,6 +561,74 @@ await Assert.ThrowsAsync( Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); } + /// + /// Verifies that DeliverContentAsync passes the declared expected hash to IDownloadService.DownloadFileAsync. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_WithDeclaredHash_PassesExpectedHashToDownloadServiceAsync() + { + // Arrange + const string expectedHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + var zipPath = Path.Combine(_tempDir, "test_hash.zip"); + CreateTestZip(zipPath); + + string? capturedExpectedHash = null; + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => + { + capturedExpectedHash = hash; + File.Copy(zipPath, path, true); + }) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + Hash = expectedHash, + }, + ], + InstallationInstructions = new InstallationInstructions + { + DownloadHash = expectedHash, + }, + }; + + var targetDir = Path.Combine(_tempDir, "hash_delivery"); + Directory.CreateDirectory(targetDir); + + // Act + var result = await _deliverer.DeliverContentAsync(manifest, targetDir); + + // Assert + Assert.True(result.Success); + Assert.Equal(expectedHash, capturedExpectedHash); + } + private static void CreateTestZip(string zipPath) { using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs index a016b37fc..db6aaaff6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs @@ -1,5 +1,6 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.GeneralsOnline; using GenHub.Core.Models.Providers; using GenHub.Features.Content.Services.GeneralsOnline; using Microsoft.Extensions.Logging.Abstractions; @@ -91,4 +92,34 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectlyAsync() var item = result.Data.First(); Assert.Equal("111825_QFE2", item.Version); } + + /// + /// Tests that ParseAsync correctly populates the SHA256 hash when present in the API response. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ParseAsync_WithSha256_PopulatesSha256OnReleaseAsync() + { + // Arrange + const string expectedSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + var json = $@"{{ + ""version"": ""111825_QFE2"", + ""download_url"": ""https://example.com/download.zip"", + ""size"": 123456, + ""sha256"": ""{expectedSha256}"", + ""release_notes"": ""Fixes stuff"" + }}"; + + var wrapper = $"{{\"source\":\"manifest\",\"data\":{json}}}"; + + // Act + var result = await _parser.ParseAsync(wrapper, _provider); + + // Assert + Assert.True(result.Success); + var item = result.Data.First(); + var release = item.GetData(); + Assert.NotNull(release); + Assert.Equal(expectedSha256, release.Sha256); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs index 68a1e4434..200f82338 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs @@ -401,4 +401,33 @@ public void DependencyBuilder_GetDependenciesForGameData_ReturnsExpectedDependen var resolvedClientDep = resolvedDeps.First(d => d.DependencyType == ContentType.GameClient); Assert.Equal(expectedClientId.Value, resolvedClientDep.Id.Value); } + + /// + /// Verifies that CreateManifests propagates Sha256 to file hash and installation instructions download hash. + /// + [Fact] + public void CreateManifests_WithSha256_SetsFileHashAndDownloadHash() + { + // Arrange + const string expectedHash = "abc123hash"; + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/GeneralsOnline_portable_101525_QFE5.zip", + PortableSize = 1048576, + Sha256 = expectedHash, + Changelog = "https://example.com/changelog", + }; + + // Act + var manifests = _factory.CreateManifests(release); + + // Assert + var gameClient = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient); + Assert.NotNull(gameClient); + Assert.Equal(expectedHash, gameClient.InstallationInstructions?.DownloadHash); + var zipFile = Assert.Single(gameClient.Files); + Assert.Equal(expectedHash, zipFile.Hash); + } } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs index 53e9cb95e..c04d78760 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs @@ -272,11 +272,20 @@ private static void CleanupTempArtifacts(string? zipPath, string? extractPath, I CurrentFile = zipFile.RelativePath, }); - logger.LogDebug("Downloading ZIP from {Url} to {Path}", zipFile.DownloadUrl, zipPath); + var expectedHash = !string.IsNullOrWhiteSpace(zipFile.Hash) + ? zipFile.Hash + : packageManifest.InstallationInstructions?.DownloadHash; + + if (string.IsNullOrWhiteSpace(expectedHash)) + { + expectedHash = null; + } + + logger.LogDebug("Downloading ZIP from {Url} to {Path} (expected hash: {Hash})", zipFile.DownloadUrl, zipPath, expectedHash); var downloadResult = await downloadService.DownloadFileAsync( new Uri(zipFile.DownloadUrl!), zipPath, - expectedHash: null, + expectedHash: expectedHash, progress: null, cancellationToken); diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs index d2ab6ed83..91f2ed956 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs @@ -148,6 +148,7 @@ private static GeneralsOnlineRelease CreateReleaseFromApiResponse(GeneralsOnline ReleaseDate = versionDate, PortableUrl = apiResponse.DownloadUrl, PortableSize = apiResponse.Size, + Sha256 = apiResponse.Sha256, Changelog = apiResponse.ReleaseNotes ?? $"Generals Online {apiResponse.Version}", }; } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 10c87c14f..712b76997 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -111,13 +111,14 @@ public ContentManifest CreateVariantManifest( DownloadUrl = release.PortableUrl, Size = release.PortableSize ?? 0, // Use 0 when size is unknown SourceType = ContentSourceType.RemoteDownload, - Hash = string.Empty, + Hash = release.Sha256 ?? string.Empty, }, ], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), InstallationInstructions = new InstallationInstructions { WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = release.Sha256, PostInstallSteps = [ new InstallationStep diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 933b635d9..766532f76 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -956,7 +956,7 @@ private async Task> AdoptExpectedChildProcessAs // Terminated first, so the launcher has exited and its stderr drains in full. return OperationResult.CreateFailure( AppendLauncherErrors( - $"Cannot adopt {expectedName}: the launcher's start time could not be read.", + $"Launcher exited without starting {expectedName}: the launcher's start time could not be read.", launcher, capturedErrors)); }