diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index dad16f47a..bebc15802 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -125,6 +125,23 @@ public static class GeneralsOnlineConstants /// Description for Generals Online deliverer. public const string DelivererDescription = "Delivers Generals Online content via ZIP extraction and CAS storage"; + // ===== 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"; + + /// Unique step key identifying Easy Anti-Cheat installation for Generals Online. + public const string EacStepKey = PublisherType + ":eac:" + EacProductId; + // ===== Content Tags ===== /// Content tags for search and categorization. diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs index 27f2cd99a..5ca6cabfb 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,19 @@ 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, + }; + /// /// 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..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); } @@ -76,11 +92,41 @@ public static string GetSafeParentDirectory(string path) /// 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); + if (string.IsNullOrWhiteSpace(baseDirectory) || string.IsNullOrWhiteSpace(candidatePath)) + { + return false; + } + + try + { + var normalizedRoot = Path.GetFullPath(baseDirectory); + var normalizedTarget = Path.GetFullPath(candidatePath); - return IsContained(normalizedRoot, normalizedTarget) && - IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); + return IsContained(normalizedRoot, normalizedTarget) && + IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); + } + catch + { + return false; + } + } + + /// + /// 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) @@ -93,31 +139,52 @@ 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 existing = fullPath; - var remainder = string.Empty; + var normalized = Path.GetFullPath(fullPath); + var root = Path.GetPathRoot(normalized); + if (string.IsNullOrEmpty(root)) + { + return normalized; + } + + var relativeFromRoot = Path.GetRelativePath(root, normalized); + if (relativeFromRoot == "." || relativeFromRoot.Length == 0) + { + return root; + } + + var segments = relativeFromRoot.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); - while (!Directory.Exists(existing) && !File.Exists(existing)) + var current = root; + foreach (var segment in segments) { - var parent = Path.GetDirectoryName(existing); - if (string.IsNullOrEmpty(parent)) + 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); - remainder = Path.Combine(Path.GetFileName(existing), remainder); - existing = parent; + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + current = FollowLinks(target.FullName, maxDepth - 1); + } + } } - 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)); + return Path.GetFullPath(current); } catch (IOException) { @@ -127,6 +194,10 @@ private static string FollowLinks(string fullPath) { return fullPath; } + catch (SecurityException) + { + return fullPath; + } catch (NotSupportedException) { return fullPath; diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs new file mode 100644 index 000000000..74f9cd2a3 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -0,0 +1,32 @@ +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 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. + /// 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. + /// A result indicating whether all post-installation steps succeeded. + Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + string? providerSource = null, + bool force = false, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} 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/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs index e707f019e..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. /// @@ -207,26 +214,42 @@ IContentManifestBuilder AddDependency( IContentManifestBuilder WithInstallationInstructions(WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy); /// - /// Adds a pre-installation step. + /// Sets the complete installation instructions object for the manifest. /// - /// Step name. - /// Command to execute. - /// Command arguments. - /// Working directory for the command. - /// Whether elevation is required. + /// The installation instructions object. /// The builder instance for chaining. - IContentManifestBuilder AddPreInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false); + IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions); /// /// 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. + /// 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, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false); + IContentManifestBuilder AddPostInstallStep( + 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 post-installation step using an existing instance. + /// + /// The installation step to add. + /// The builder instance for chaining. + IContentManifestBuilder AddPostInstallStep(InstallationStep step); /// /// Adds a content reference for cross-publisher linking. @@ -244,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 c33263307..fd4c33fe7 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,29 @@ 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 != null && 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 ??= []; + 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. @@ -181,6 +209,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.Core/Models/Enums/InstallationStepKind.cs b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs new file mode 100644 index 000000000..1389e130b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs @@ -0,0 +1,30 @@ +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 +{ + /// + /// 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/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.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.Core/Models/Manifest/InstallationStep.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs index 6ecd505a9..78590ebfd 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,49 @@ 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; } + + /// + /// 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/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 21b6cd066..3be9893e8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -1,11 +1,18 @@ +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.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; @@ -15,14 +22,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 +48,22 @@ 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(), + 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 +71,101 @@ 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", "Test Provider", false, It.IsAny>(), It.IsAny()), Times.Once); validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once); } + /// + /// Verifies that PrepareContentAsync fails and triggers rollback 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(), + 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); + 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>(), + 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(() => provider.PrepareContentAsync(manifest, "/tmp/test")); + + Assert.True(provider.RollbackCalled); + } + /// /// Verifies that PrepareContentAsync fails when manifest validation fails with errors. /// @@ -60,6 +175,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 +191,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"); @@ -94,13 +216,16 @@ private class TestContentProvider : BaseContentProvider private readonly IContentResolver _resolver; private readonly IContentDeliverer _deliverer; + public bool RollbackCalled { get; private set; } + 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,9 +242,19 @@ public TestContentProvider( protected override IContentDeliverer Deliverer => _deliverer; - public override Task> GetValidatedContentAsync(string contentId, CancellationToken cancellationToken = default) + public override Task> GetValidatedContentAsync( + string contentId, + CancellationToken cancellationToken = default) { - var manifest = new ContentManifest { Id = contentId, Name = $"Content {contentId}" }; + 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)); } @@ -128,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 a50f687ff..8ecfe2931 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,23 @@ 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(), + 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..2eb524a35 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -0,0 +1,1000 @@ +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.Content; +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; +using GenHub.Features.Content.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +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 Mock _userSettingsServiceMock; + 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}"); + Directory.CreateDirectory(_tempDirectory); + + _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); + } + + /// + /// Cleans up temporary resources after test execution. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignore cleanup error + } + } + } + + /// + /// 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() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions(); + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + } + + /// + /// 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_UntrustedProvider_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Malicious Executable", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "malicious.exe", + }, + ], + }; + + // 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 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 + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Delete Something", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = "important.dat", + }, + new InstallationStep + { + Name = "Rename Something", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "source.dat", + DestinationRelativePath = "dest.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); + Assert.True(File.Exists(importantFilePath)); + Assert.True(File.Exists(sourceFilePath)); + Assert.False(File.Exists(destFilePath)); + } + + /// + /// Verifies that paths attempting directory traversal are rejected. + /// + /// A task representing the asynchronous unit test. + [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, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + 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() + { + 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, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + 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() + { + 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, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + 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() + { + 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, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + 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() + { + 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, + StepKey = "test_rename_step", + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.False(File.Exists(sourceFullPath)); + Assert.True(File.Exists(destFullPath)); + Assert.Equal("hello world", File.ReadAllText(destFullPath)); + 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.exe" : "test_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + 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 + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.True(_userSettings.IsInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey)); + _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); + } + + /// + /// 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() + { + 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, providerSource: PublisherTypeConstants.GeneralsOnline); + + 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); + } + + /// + /// 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.exe" : "test_force_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + 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 + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], + 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, providerSource: PublisherTypeConstants.GeneralsOnline, force: true); + + Assert.True(result.Success); + _notificationServiceMock.Verify( + n => n.ShowInfo( + GeneralsOnlineConstants.EacStepName, + GeneralsOnlineConstants.EacStatusMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that unknown installation step kinds return failure. + /// + /// A task representing the asynchronous unit test. + [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, 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 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.exe" : "sleep_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + 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, + Arguments = OperatingSystem.IsWindows() ? ["/c", "ping", "-n", "30", "127.0.0.1"] : [], + }, + ], + }; + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(200)); + + await Assert.ThrowsAnyAsync(() => + _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline, + 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); + } + + /// + /// 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", + 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/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.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/GeneralsOnlineManifestFactoryEacTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs index 04e9b80a9..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 @@ -129,6 +129,115 @@ 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.True(eacStep.RunOnce); + Assert.Equal(GeneralsOnlineConstants.EacStepKey, eacStep.StepKey); + 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); + } + + /// + /// 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() { @@ -170,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( @@ -178,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/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.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..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,13 +51,23 @@ 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, [_resolverMock.Object], [_delivererMock.Object], _validatorMock.Object, - NullLogger.Instance); + NullLogger.Instance, + _instructionsServiceMock.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.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs index aad8be697..eafa0df4c 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,60 @@ 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.FullCopy, + 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.FullCopy, 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 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..9bdd35e8b 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. @@ -144,6 +178,51 @@ 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. + /// + [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")); @@ -169,4 +248,26 @@ 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 (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + } } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs index 3ed1922ae..79ae5fcde 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, @@ -32,11 +33,10 @@ 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; - private readonly IContentDiscoverer _discoverer = discoverers.FirstOrDefault(d => d.SourceName.Contains(CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("No Community Outpost discoverer found"); @@ -127,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/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs index 42ee8b423..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, @@ -173,7 +174,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/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..be26d3495 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -18,13 +18,27 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// /// Base class for content providers with common pipeline orchestration logic. /// -public abstract class BaseContentProvider( - IContentValidator contentValidator, - ILogger logger -) : IContentProvider +public abstract class BaseContentProvider : IContentProvider { - private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly IContentValidator _contentValidator = contentValidator ?? throw new ArgumentNullException(nameof(contentValidator)); + private readonly IContentValidator _contentValidator; + private readonly IInstallationInstructionsService _installationInstructionsService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The content validator. + /// The installation instructions service. + /// The logger. + protected BaseContentProvider( + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, + ILogger logger) + { + _contentValidator = contentValidator; + _installationInstructionsService = installationInstructionsService; + _logger = logger; + } /// public abstract string SourceName { get; } @@ -89,7 +103,7 @@ public virtual async Task>> Sea Logger.LogWarning( "Resolution failed for {ContentName}: {Error}", discovered.Name, - resolutionResult.FirstError ?? "Unknown error"); + resolutionResult.FirstError); } } else @@ -101,12 +115,7 @@ public virtual async Task>> Sea return OperationResult>.CreateSuccess(resolvedResults); } - /// - /// 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( string contentId, CancellationToken cancellationToken = default); @@ -149,50 +158,99 @@ public virtual async Task> PrepareContentAsync( // Delegate to implementation-specific preparation var result = await PrepareContentInternalAsync(manifest, workingDirectory, progress, cancellationToken); - if (result.Success) + if (!result.Success) { - // Final validation of prepared content - progress?.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.ValidatingFiles, - CurrentOperation = "Validating prepared content...", - }); + return result; + } - // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress - IProgress? validationProgress = null; - if (progress != null) - { - validationProgress = new Progress(vp => - { - // 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, - }); - }); - } + 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}."); + } - var fullResult = await ContentValidator.ValidateAllAsync( + try + { + // Execute post-installation steps if declared on the delivered manifest + var stepExecutionResult = await _installationInstructionsService.ExecutePostInstallStepsAsync( + result.Data, workingDirectory, - result.Data!, - validationProgress, + providerSource: SourceName, + progress: progress, cancellationToken: cancellationToken); - if (!fullResult.IsValid) + if (!stepExecutionResult.Success) { - // 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); - } + 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...", + }); + + // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress + IProgress? validationProgress = null; + if (progress != null) + { + validationProgress = new Progress(vp => + { + // 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, + }); + }); + } + + 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); + } + + 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; } @@ -208,16 +266,55 @@ 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; + } + + /// + /// 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. /// - protected ILogger Logger => logger; + protected ILogger Logger => _logger; /// /// Gets the content validator for manifest validation. /// protected IContentValidator ContentValidator => _contentValidator; + /// + /// Gets the installation instructions service for post-install execution. + /// + protected IInstallationInstructionsService? InstallationInstructionsService => _installationInstructionsService; + /// /// Gets the discoverer for this provider. /// @@ -315,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/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/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs new file mode 100644 index 000000000..f538f8c05 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -0,0 +1,106 @@ +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; + +/// +/// Precondition that checks whether Easy Anti-Cheat EOS product ID is already registered in the Windows registry. +/// +/// Optional logger instance for diagnostics. +public class EasyAntiCheatPrecondition(ILogger? logger = null) : IInstallationStepPrecondition +{ + /// + public bool CanHandle(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows() || step == null || manifest == null) + { + return false; + } + + if (step.Kind != InstallationStepKind.RunVerifiedInstaller) + { + 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); + } + + /// + public bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + return IsProductRegisteredOnWindows(step); + } + + [SupportedOSPlatform("windows")] + private bool IsProductRegisteredOnWindows(InstallationStep step) + { + try + { + var productId = (step.Arguments is { Count: > 1 } && !string.IsNullOrWhiteSpace(step.Arguments[1])) + ? step.Arguments[1] + : GeneralsOnlineConstants.EacProductId; + + if (string.IsNullOrWhiteSpace(productId)) + { + return false; + } + + var subKeyPath = $@"SOFTWARE\EasyAntiCheat_EOS\{productId}"; + + using var baseKey32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + using var key32 = baseKey32.OpenSubKey(subKeyPath); + if (key32 != null) + { + return true; + } + + using var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var key64 = baseKey64.OpenSubKey(subKeyPath); + if (key64 != null) + { + return true; + } + } + 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; + } + + return false; + } +} 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 59a2eb756..712b76997 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; @@ -96,10 +111,29 @@ 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 + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }, }; } @@ -323,6 +357,7 @@ private ContentManifest CreateGameDataPatchManifest(GeneralsOnlineRelease releas // Files will be populated during extraction Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }; } @@ -378,18 +413,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 +476,10 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), + InstallationInstructions = originalManifest.InstallationInstructions ?? new InstallationInstructions + { + WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + }, }); // Create QuickMatch MapPack @@ -469,6 +509,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest [ GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(), ], + InstallationInstructions = new InstallationInstructions(), }); // Create GeneralsOnlineGameData data patch @@ -495,6 +536,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }); return manifests; @@ -520,10 +562,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) { @@ -532,11 +627,9 @@ 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); @@ -547,151 +640,137 @@ private async Task> UpdateManifestsWithExtractedFiles( 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}'."); + manifestFiles.Add(new ManifestFile + { + 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 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 => s != null && (hasEacSetup || !string.Equals( + s.TargetRelativePath, + GameClientConstants.GeneralsOnlineEacSetupExecutable, + StringComparison.OrdinalIgnoreCase))); + + var instructions = new InstallationInstructions + { + WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = manifest.InstallationInstructions?.DownloadHash, + PostInstallSteps = [.. inheritedPostSteps], + }; + + if (manifest.ContentType == ContentType.GameClient && + hasEacSetup && + 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 { - 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, + 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) { - foreach (var m in updatedManifests) + return; + } + + foreach (var m in manifests) + { + 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; } } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index 3c842779c..6a800a122 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; @@ -28,10 +29,12 @@ public class GeneralsOnlineProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, IContentManifestPool manifestPool, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { + private readonly ConcurrentDictionary> _preExistingManifestIdsByManifest = new(StringComparer.OrdinalIgnoreCase); private ProviderDefinition? _cachedProviderDefinition; /// @@ -201,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 @@ -212,6 +221,21 @@ protected override async Task> PrepareContentIn $"Cannot deliver content for manifest {manifest.Id}"); } + 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); + foreach (var m in existingPool.Data) + { + preExisting.Add(m.Id); + } + + _preExistingManifestIdsByManifest[manifest.Id] = preExisting; + var deliveryResult = await Deliverer.DeliverContentAsync( manifest, workingDirectory, @@ -241,4 +265,63 @@ 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 + { + 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) + { + var matchingManifests = allManifestsResult.Data + .Where(m => string.Equals(m.Version, preparedManifest.Version, StringComparison.OrdinalIgnoreCase) && + string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase) && + !preExistingIds.Contains(m.Id)) + .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.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/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..bae5c14e2 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -0,0 +1,657 @@ +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 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); + private readonly SemaphoreSlim _executionGate = new(1, 1); + + /// + /// 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 ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + string? providerSource = null, + bool force = false, + 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} 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); + } + + private async Task ExecuteStepsAsync( + IReadOnlyList steps, + ContentManifest manifest, + string workingDirectory, + string? providerSource, + bool force, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workingDirectory) || !Directory.Exists(workingDirectory)) + { + return OperationResult.CreateFailure($"Working directory does not exist: '{workingDirectory}'"); + } + + await _executionGate.WaitAsync(cancellationToken); + try + { + 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, providerSource, force, progress, cancellationToken); + if (!stepResult.Success) + { + return stepResult; + } + } + + return OperationResult.CreateSuccess(); + } + finally + { + _executionGate.Release(); + } + } + + private async Task ExecuteSingleStepAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + string? providerSource, + bool force, + IProgress? progress, + CancellationToken cancellationToken) + { + var stepKey = GetStepKey(step, manifest); + + 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})", + step.Name, + manifest.Id, + stepKey); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Delivering, + CurrentOperation = $"Skipping {step.Name} (already installed)", + CurrentFile = step.TargetRelativePath ?? string.Empty, + }); + + return OperationResult.CreateSuccess(); + } + + var authResult = ValidateProviderAuthorization(providerSource, manifest, step); + if (!authResult.Success) + { + return authResult; + } + + var result = OperationResult.CreateFailure("Uninitialized step result"); + switch (step.Kind) + { + case InstallationStepKind.RunVerifiedInstaller: + result = await ExecuteRunVerifiedInstallerAsync(step, manifest, workingDirectory, progress, cancellationToken); + break; + + case InstallationStepKind.RemoveFile: + result = ExecuteRemoveFile(step, workingDirectory); + break; + + case InstallationStepKind.RenameFile: + result = ExecuteRenameFile(step, workingDirectory); + break; + + 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 && step.RunOnce && !string.IsNullOrWhiteSpace(stepKey)) + { + await RecordStepExecutedAsync(stepKey, cancellationToken); + } + + return result; + } + + private async Task ShouldSkipStepAsync( + InstallationStep step, + string stepKey, + ContentManifest manifest, + CancellationToken cancellationToken) + { + if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true) + { + return true; + } + + if (preconditions != null) + { + foreach (var precondition in preconditions) + { + if (precondition.CanHandle(step, manifest) && precondition.IsAlreadyFulfilled(step, manifest)) + { + if (!string.IsNullOrWhiteSpace(stepKey)) + { + await RecordStepExecutedAsync(stepKey, cancellationToken); + } + + return true; + } + } + } + + 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)) + { + return step.StepKey; + } + + 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}:{manifestId}:{name}:{target}:{args}".TrimEnd(':'); + } + + private async Task ExecuteRunVerifiedInstallerAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + 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 ValidateProviderAuthorization(string? providerSource, ContentManifest manifest, InstallationStep step) + { + var effectiveSource = !string.IsNullOrWhiteSpace(providerSource) + ? providerSource + : string.Empty; + + var isTrusted = PublisherTypeConstants.TrustedExecutablePublishers.Contains(effectiveSource); + + if (!isTrusted) + { + logger.LogError( + "Untrusted provider '{ProviderSource}' attempted to execute step '{StepName}' (Kind: {Kind}) for manifest {ManifestId}", + effectiveSource, + step.Name, + step.Kind, + manifest.Id); + + return OperationResult.CreateFailure( + $"Provider '{(!string.IsNullOrEmpty(effectiveSource) ? effectiveSource : "unknown")}' is not authorized to execute installation steps."); + } + + 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); + targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + + 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."); + } + + 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."); + } + + 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), + 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)) + { + 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."); + } + + var computedHash = string.Empty; + 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( + "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); + return OperationResult.CreateSuccess(); + } + + 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 + : $"Executing verified installer '{step.TargetRelativePath}'"; + + notificationService.ShowInfo( + displayTitle, + displayMessage, + NotificationConstants.DefaultAutoDismissMs); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Delivering, + CurrentOperation = displayMessage, + CurrentFile = step.TargetRelativePath ?? string.Empty, + }); + } + + private async Task RunInstallerProcessAsync( + InstallationStep step, + string targetFullPath, + string workingDirectory, + CancellationToken cancellationToken) + { + 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) + { + 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"; + } + 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}'."); + } + + 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); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + 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."); + } + 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); + await process.WaitForExitAsync(CancellationToken.None); + } + } + catch (Exception killEx) + { + logger.LogWarning(killEx, "Failed to terminate canceled installer step '{StepName}'", step.Name); + } + + throw; + } + + 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.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."); + } + + 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.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.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."); + } + + 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..d653e90f4 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, - ILogger logger) - : BaseContentProvider(contentValidator, logger) + ILogger logger, + IInstallationInstructionsService installationInstructionsService) + : 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/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)); } diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index fcecce36f..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. /// @@ -512,6 +544,7 @@ public Task AddContentAddressableFileAsync( { RelativePath = relativePath, SourceType = ContentSourceType.ContentAddressable, + InstallTarget = DetermineInstallTarget(relativePath), IsExecutable = isExecutable, Hash = hash, Size = size, @@ -614,69 +647,86 @@ public IContentManifestBuilder AddRequiredDirectories(params string[] directorie public IContentManifestBuilder WithInstallationInstructions( WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy) { - _manifest.InstallationInstructions = new InstallationInstructions - { - WorkspaceStrategy = workspaceStrategy, - }; + _manifest.InstallationInstructions = _manifest.InstallationInstructions == null + ? new InstallationInstructions { WorkspaceStrategy = workspaceStrategy } + : new InstallationInstructions + { + WorkspaceStrategy = workspaceStrategy, + DownloadHash = _manifest.InstallationInstructions.DownloadHash, + PostInstallSteps = _manifest.InstallationInstructions.PostInstallSteps == null + ? [] + : [.. _manifest.InstallationInstructions.PostInstallSteps], + }; + 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 AddPreInstallStep( - string name, - string command, - List? arguments = null, - string workingDirectory = "", - bool requiresElevation = false) + /// + public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions) { - var step = new InstallationStep + ArgumentNullException.ThrowIfNull(installationInstructions); + + _manifest.InstallationInstructions = new InstallationInstructions { - Name = name, - Command = command, - Arguments = arguments ?? [], - WorkingDirectory = workingDirectory, - RequiresElevation = requiresElevation, + WorkspaceStrategy = installationInstructions.WorkspaceStrategy, + DownloadHash = installationInstructions.DownloadHash, + PostInstallSteps = installationInstructions.PostInstallSteps == null + ? [] + : [.. installationInstructions.PostInstallSteps], }; - _manifest.InstallationInstructions.PreInstallSteps.Add(step); - logger.LogDebug("Added pre-install step: {StepName}", name); + + logger.LogDebug( + "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps", + _manifest.InstallationInstructions.WorkspaceStrategy, + _manifest.InstallationInstructions.PostInstallSteps.Count); 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, + bool runOnce = false, + string? stepKey = 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, + RunOnce = runOnce, + StepKey = stepKey, }; + return AddPostInstallStep(step); + } + + /// + 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}", name); + logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce); return this; } diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index ea7246dec..322c7bbe2 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -361,5 +361,11 @@ 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(); } }