From eef2b469c46c3a6fe11629cbd8875ef7a8233ba6 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 18:50:01 +0000 Subject: [PATCH] fix(workspace): prioritize custom files over installation data and isolate user workspaces - Add GetPrioritizedWorkspaceFiles extension to deduplicate workspace files with strict ContentTypePriority ordering regardless of manifest order - Update FullCopyStrategy, HardLinkStrategy, HybridCopySymlinkStrategy, and SymlinkOnlyStrategy to use prioritized workspace files - Fix false-negative test skip conditions in workspace and CAS integration tests on Linux and macOS - Expand WorkspacePrioritizationVerifyTests with comprehensive hierarchy, order-independence, and user workspace isolation tests Closes #42 --- .../WorkspaceConfigurationExtensions.cs | 53 +++- .../GameProfileWorkspaceIntegrationTest.cs | 8 +- .../Workspace/WorkspaceIntegrationTests.cs | 2 +- .../WorkspacePrioritizationVerifyTests.cs | 288 ++++++++++++++++-- .../WorkspaceCasIntegrationTests.cs | 6 +- .../Workspace/Strategies/FullCopyStrategy.cs | 114 +++---- .../Workspace/Strategies/HardLinkStrategy.cs | 19 +- .../Strategies/HybridCopySymlinkStrategy.cs | 186 ++++++----- .../Strategies/SymlinkOnlyStrategy.cs | 21 +- 9 files changed, 473 insertions(+), 224 deletions(-) diff --git a/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs b/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs index 36d661050..b2b0aaf07 100644 --- a/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs +++ b/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs @@ -1,4 +1,7 @@ +using System; +using System.Collections.Generic; using System.Linq; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Workspace; @@ -11,34 +14,74 @@ public static class WorkspaceConfigurationExtensions { /// /// Gets all unique files from all manifests, deduplicated by relative path. - /// When multiple manifests contain the same file path, returns the first occurrence. + /// When multiple manifests contain the same file path, returns the file from the highest priority manifest. /// /// The workspace configuration to get files from. /// An enumerable of unique manifest files. public static IEnumerable GetAllUniqueFiles( this WorkspaceConfiguration configuration) { + if (configuration?.Manifests is null || configuration.Manifests.Count == 0) + { + return []; + } + return configuration.Manifests - .SelectMany(m => (m.Files ?? []).Select(f => new { File = f, Manifest = m })) + .SelectMany((m, index) => (m.Files ?? []).Select(f => new { File = f, Manifest = m, ManifestIndex = index })) .GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase) .Select(g => g.OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType)) + .ThenByDescending(x => x.ManifestIndex) .First().File); } /// /// Gets all unique files intended for the workspace from all manifests, deduplicated by relative path. - /// Only includes files where is . + /// Only includes files where is . + /// Higher-priority manifests (such as mods and patches) take precedence over lower-priority manifests (such as base game installations). /// /// The workspace configuration to get files from. /// An enumerable of unique workspace-specific manifest files. public static IEnumerable GetWorkspaceUniqueFiles( this WorkspaceConfiguration configuration) { + if (configuration?.Manifests is null || configuration.Manifests.Count == 0) + { + return []; + } + return configuration.Manifests - .SelectMany(m => (m.Files ?? []).Select(f => new { File = f, Manifest = m })) - .Where(x => x.File.InstallTarget == GenHub.Core.Models.Enums.ContentInstallTarget.Workspace) + .SelectMany((m, index) => (m.Files ?? []).Select(f => new { File = f, Manifest = m, ManifestIndex = index })) + .Where(x => x.File.InstallTarget == ContentInstallTarget.Workspace) .GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase) .Select(g => g.OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType)) + .ThenByDescending(x => x.ManifestIndex) .First().File); } + + /// + /// Gets all unique workspace files paired with their owning manifest, deduplicated by relative path + /// and resolved by content type priority (higher-priority content types override lower-priority ones). + /// + /// The workspace configuration to get prioritized files from. + /// A read-only list of prioritized file and manifest pairs. + public static IReadOnlyList<(ManifestFile File, ContentManifest Manifest)> GetPrioritizedWorkspaceFiles( + this WorkspaceConfiguration configuration) + { + if (configuration?.Manifests is null || configuration.Manifests.Count == 0) + { + return []; + } + + return configuration.Manifests + .SelectMany((manifest, index) => (manifest.Files ?? Enumerable.Empty()) + .Where(f => f.InstallTarget == ContentInstallTarget.Workspace) + .Select(file => new { File = file, Manifest = manifest, ManifestIndex = index })) + .GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase) + .Select(g => g + .OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType)) + .ThenByDescending(x => x.ManifestIndex) + .First()) + .Select(x => (x.File, x.Manifest)) + .ToList(); + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs index b8ed64087..ea1db3e90 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs @@ -163,14 +163,14 @@ public async Task PrepareWorkspace_FullCopyStrategy_CopiesGameInstallationAndCli [Fact] public async Task PrepareWorkspace_SymlinkStrategy_LinksGameInstallationAndClientFilesAsync() { - // Skip on systems that don't support symlinks + // Skip on Windows if not running with administrator privileges bool isWindows = OperatingSystem.IsWindows(); bool isAdmin = isWindows && new System.Security.Principal.WindowsPrincipal( System.Security.Principal.WindowsIdentity.GetCurrent()) .IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); - if (!isWindows || !isAdmin) + if (isWindows && !isAdmin) { return; } @@ -228,7 +228,7 @@ public async Task PrepareWorkspace_MixedContentTypes_HandlesGameInstallationAndG System.Security.Principal.WindowsIdentity.GetCurrent()) .IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); - if (!isWindows || !isAdmin) + if (isWindows && !isAdmin) { return; } @@ -336,7 +336,7 @@ public async Task PrepareWorkspace_AllStrategies_HandleGameInstallationFilesAsyn // Skip symlink strategies when not admin on Windows if ((strategy == WorkspaceStrategy.SymlinkOnly || strategy == WorkspaceStrategy.HybridCopySymlink) && - (!isWindows || !isAdmin)) + isWindows && !isAdmin) { return; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs index 63c16a164..4ecb78c22 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs @@ -114,7 +114,7 @@ public async Task EndToEndWorkspaceCreation_AllStrategiesAsync(WorkspaceStrategy if ((strategy == WorkspaceStrategy.SymlinkOnly || strategy == WorkspaceStrategy.HybridCopySymlink) && - (!isWindows || !isAdmin)) + isWindows && !isAdmin) { return; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs index 336fbc985..afba3fc71 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs @@ -1,15 +1,28 @@ +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Extensions; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Interfaces.Workspace; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameClients; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Workspace; +using GenHub.Features.Workspace; +using GenHub.Features.Workspace.Strategies; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; using Xunit; namespace GenHub.Tests.Core.Features.Workspace; /// -/// Verification tests for workspace file prioritization logic. +/// Verification tests for workspace file prioritization logic and user directory workspace isolation. /// public class WorkspacePrioritizationVerifyTests { @@ -20,23 +33,23 @@ public class WorkspacePrioritizationVerifyTests public void GetAllUniqueFiles_ShouldPrioritizeGameClientOverInstallation() { // Arrange - var commonFile = new ManifestFile { RelativePath = "data.ini", Size = 100 }; + var lowPriorityFile = new ManifestFile { RelativePath = "data.ini", Size = 100, SourcePath = "install" }; + var highPriorityFile = new ManifestFile { RelativePath = "data.ini", Size = 150, SourcePath = "client" }; var installationManifest = new ContentManifest { Id = new ManifestId("install"), - ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, - Files = [commonFile], + ContentType = ContentType.GameInstallation, + Files = [lowPriorityFile], }; var clientManifest = new ContentManifest { Id = new ManifestId("client"), - ContentType = GenHub.Core.Models.Enums.ContentType.GameClient, - Files = [commonFile], // Same file + ContentType = ContentType.GameClient, + Files = [highPriorityFile], }; - // Order matters for the BUG: usually installation comes first var config = new WorkspaceConfiguration { Manifests = [installationManifest, clientManifest], @@ -47,16 +60,19 @@ public void GetAllUniqueFiles_ShouldPrioritizeGameClientOverInstallation() // Assert Assert.Single(result); - - // We can't easily check WHICH file it is since they are identical objects/values here, - // so let's make them distinguishable. + Assert.Equal(150, result[0].Size); + Assert.Equal("client", result[0].SourcePath); } /// - /// Verifies that high-priority content (like mods) correctly overwrites low-priority content (like installations). + /// Verifies that high-priority content (such as mods) correctly overwrites low-priority content (such as installations), + /// regardless of whether the installation or the mod appears first in the manifests list. /// - [Fact] - public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent() + /// Whether the mod manifest appears first in the collection. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent_RegardlessOfOrder(bool modFirst) { // Arrange var lowPriorityFile = new ManifestFile { RelativePath = "config.ini", Size = 100, SourcePath = "low" }; @@ -65,21 +81,20 @@ public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent() var installationManifest = new ContentManifest { Id = new ManifestId("install"), - ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + ContentType = ContentType.GameInstallation, Files = [lowPriorityFile], }; var modManifest = new ContentManifest { Id = new ManifestId("mod"), - ContentType = GenHub.Core.Models.Enums.ContentType.Mod, + ContentType = ContentType.Mod, Files = [highPriorityFile], }; - // Put installation first to trigger the potential bug (if it picks first) var config = new WorkspaceConfiguration { - Manifests = [installationManifest, modManifest], + Manifests = modFirst ? [modManifest, installationManifest] : [installationManifest, modManifest], }; // Act @@ -87,10 +102,245 @@ public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent() // Assert Assert.Single(uniqueFiles); - var chosenFile = uniqueFiles.First(); + var chosenFile = uniqueFiles[0]; - // Should be the mod file (size 200) + // Should always be the mod file (size 200) Assert.Equal(200, chosenFile.Size); Assert.Equal("high", chosenFile.SourcePath); } + + /// + /// Verifies that GetPrioritizedWorkspaceFiles returns the winning file and manifest pair for all content types. + /// + [Fact] + public void GetPrioritizedWorkspaceFiles_FullHierarchy_ResolvesCorrectWinningManifests() + { + // Arrange: Mod (100) > Patch (90) > GameClient (50) > ModdingTool (45) > Addon (40) > LanguagePack (35) > Map (30) > GameInstallation (10) + var fileMod = new ManifestFile { RelativePath = "shared.ini", Size = 1000, InstallTarget = ContentInstallTarget.Workspace }; + var filePatch = new ManifestFile { RelativePath = "shared.ini", Size = 900, InstallTarget = ContentInstallTarget.Workspace }; + var fileClient = new ManifestFile { RelativePath = "shared.ini", Size = 500, InstallTarget = ContentInstallTarget.Workspace }; + var fileInstall = new ManifestFile { RelativePath = "shared.ini", Size = 100, InstallTarget = ContentInstallTarget.Workspace }; + + var manifestMod = new ContentManifest { Id = new ManifestId("mod"), ContentType = ContentType.Mod, Files = [fileMod] }; + var manifestPatch = new ContentManifest { Id = new ManifestId("patch"), ContentType = ContentType.Patch, Files = [filePatch] }; + var manifestClient = new ContentManifest { Id = new ManifestId("client"), ContentType = ContentType.GameClient, Files = [fileClient] }; + var manifestInstall = new ContentManifest { Id = new ManifestId("install"), ContentType = ContentType.GameInstallation, Files = [fileInstall] }; + + // Test with installation first + var config1 = new WorkspaceConfiguration + { + Manifests = [manifestInstall, manifestClient, manifestPatch, manifestMod], + }; + + var result1 = config1.GetPrioritizedWorkspaceFiles(); + Assert.Single(result1); + Assert.Equal("mod", result1[0].Manifest.Id.Value); + Assert.Equal(1000, result1[0].File.Size); + + // Test with patch beating client and install + var config2 = new WorkspaceConfiguration + { + Manifests = [manifestInstall, manifestPatch, manifestClient], + }; + + var result2 = config2.GetPrioritizedWorkspaceFiles(); + Assert.Single(result2); + Assert.Equal("patch", result2[0].Manifest.Id.Value); + Assert.Equal(900, result2[0].File.Size); + } + + /// + /// Verifies that GetPrioritizedWorkspaceFiles filters out files not targeted to the workspace. + /// + [Fact] + public void GetPrioritizedWorkspaceFiles_FiltersNonWorkspaceInstallTargets() + { + // Arrange + var workspaceFile = new ManifestFile { RelativePath = "game.exe", Size = 1000, InstallTarget = ContentInstallTarget.Workspace }; + var userDataFile = new ManifestFile { RelativePath = "options.ini", Size = 200, InstallTarget = ContentInstallTarget.UserData }; + + var manifest = new ContentManifest + { + Id = new ManifestId("test"), + ContentType = ContentType.Mod, + Files = [workspaceFile, userDataFile], + }; + + var config = new WorkspaceConfiguration + { + Manifests = [manifest], + }; + + // Act + var result = config.GetPrioritizedWorkspaceFiles(); + + // Assert + Assert.Single(result); + Assert.Equal("game.exe", result[0].File.RelativePath); + } + + /// + /// Verifies that FullCopyStrategy materializes custom files with priority over base installation files in user workspaces. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task FullCopyStrategy_CustomFileOverInstallation_CopiesWinningFileAsync() + { + // Arrange + var tempBase = Path.Combine(Path.GetTempPath(), $"genhub_base_{Guid.NewGuid():N}"); + var tempUserWorkspace = Path.Combine(Path.GetTempPath(), $"genhub_userws_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempBase); + Directory.CreateDirectory(tempUserWorkspace); + + try + { + var baseFilePath = Path.Combine(tempBase, "GameData.ini"); + var modFilePath = Path.Combine(tempBase, "ModGameData.ini"); + var unmoddedBaseFilePath = Path.Combine(tempBase, "Generals.big"); + + await File.WriteAllTextAsync(baseFilePath, "BASE GAME CONTENT"); + await File.WriteAllTextAsync(modFilePath, "CUSTOM MOD CONTENT"); + await File.WriteAllTextAsync(unmoddedBaseFilePath, "UNTOUCHED BIG FILE"); + + var baseFile = new ManifestFile { RelativePath = "GameData.ini", SourcePath = baseFilePath, Size = 17, InstallTarget = ContentInstallTarget.Workspace }; + var modFile = new ManifestFile { RelativePath = "GameData.ini", SourcePath = modFilePath, Size = 18, InstallTarget = ContentInstallTarget.Workspace }; + var unchangedFile = new ManifestFile { RelativePath = "Generals.big", SourcePath = unmoddedBaseFilePath, Size = 18, InstallTarget = ContentInstallTarget.Workspace }; + + var installManifest = new ContentManifest { Id = new ManifestId("install"), ContentType = ContentType.GameInstallation, Files = [baseFile, unchangedFile] }; + var modManifest = new ContentManifest { Id = new ManifestId("mod"), ContentType = ContentType.Mod, Files = [modFile] }; + + var config = new WorkspaceConfiguration + { + Id = "test-ws-priority", + WorkspaceRootPath = tempUserWorkspace, + BaseInstallationPath = tempBase, + Strategy = WorkspaceStrategy.FullCopy, + GameClient = new GameClient { Id = "test-client", ExecutablePath = "game.exe" }, + Manifests = [installManifest, modManifest], + }; + + var mockFileOps = new Mock(); + mockFileOps.Setup(f => f.CopyFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (src, dst, ct) => + { + Directory.CreateDirectory(Path.GetDirectoryName(dst)!); + File.Copy(src, dst, true); + await Task.CompletedTask; + }); + + var strategy = new FullCopyStrategy(mockFileOps.Object, NullLogger.Instance); + + // Act + var workspaceInfo = await strategy.PrepareAsync(config); + + // Assert + Assert.True(workspaceInfo.IsPrepared); + var destGameData = Path.Combine(workspaceInfo.WorkspacePath, "GameData.ini"); + var destBig = Path.Combine(workspaceInfo.WorkspacePath, "Generals.big"); + + Assert.True(File.Exists(destGameData)); + Assert.True(File.Exists(destBig)); + + // Custom file content should be the mod content, not base game content + var gameDataContent = await File.ReadAllTextAsync(destGameData); + Assert.Equal("CUSTOM MOD CONTENT", gameDataContent); + + // Base game directory files must be completely unmodified + Assert.Equal("BASE GAME CONTENT", await File.ReadAllTextAsync(baseFilePath)); + Assert.Equal("UNTOUCHED BIG FILE", await File.ReadAllTextAsync(unmoddedBaseFilePath)); + } + finally + { + if (Directory.Exists(tempBase)) + { + Directory.Delete(tempBase, true); + } + + if (Directory.Exists(tempUserWorkspace)) + { + Directory.Delete(tempUserWorkspace, true); + } + } + } + + /// + /// Verifies that HybridCopySymlinkStrategy correctly prioritizes custom files over installation data, + /// even when the mod manifest is placed before or after the installation manifest. + /// + /// Whether the mod manifest appears first in the collection. + /// A representing the asynchronous unit test. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HybridCopySymlinkStrategy_CustomFileOverInstallation_PrioritizesCorrectlyAsync(bool modFirst) + { + // Arrange + var tempBase = Path.Combine(Path.GetTempPath(), $"genhub_hybrid_base_{Guid.NewGuid():N}"); + var tempUserWorkspace = Path.Combine(Path.GetTempPath(), $"genhub_hybrid_userws_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempBase); + Directory.CreateDirectory(tempUserWorkspace); + + try + { + var baseFilePath = Path.Combine(tempBase, "config.ini"); + var modFilePath = Path.Combine(tempBase, "mod_config.ini"); + + await File.WriteAllTextAsync(baseFilePath, "BASE CONFIG"); + await File.WriteAllTextAsync(modFilePath, "MOD CONFIG"); + + var baseFile = new ManifestFile { RelativePath = "config.ini", SourcePath = baseFilePath, Size = 11, InstallTarget = ContentInstallTarget.Workspace }; + var modFile = new ManifestFile { RelativePath = "config.ini", SourcePath = modFilePath, Size = 10, InstallTarget = ContentInstallTarget.Workspace }; + + var installManifest = new ContentManifest { Id = new ManifestId("install"), ContentType = ContentType.GameInstallation, Files = [baseFile] }; + var modManifest = new ContentManifest { Id = new ManifestId("mod"), ContentType = ContentType.Mod, Files = [modFile] }; + + var config = new WorkspaceConfiguration + { + Id = "test-ws-hybrid", + WorkspaceRootPath = tempUserWorkspace, + BaseInstallationPath = tempBase, + Strategy = WorkspaceStrategy.HybridCopySymlink, + GameClient = new GameClient { Id = "test-client", ExecutablePath = "game.exe" }, + Manifests = modFirst ? [modManifest, installManifest] : [installManifest, modManifest], + }; + + var mockFileOps = new Mock(); + mockFileOps.Setup(f => f.CopyFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (src, dst, ct) => + { + Directory.CreateDirectory(Path.GetDirectoryName(dst)!); + File.Copy(src, dst, true); + await Task.CompletedTask; + }); + + var strategy = new HybridCopySymlinkStrategy(mockFileOps.Object, NullLogger.Instance); + + // Act + var workspaceInfo = await strategy.PrepareAsync(config); + + // Assert + Assert.True(workspaceInfo.IsPrepared); + var destConfig = Path.Combine(workspaceInfo.WorkspacePath, "config.ini"); + Assert.True(File.Exists(destConfig)); + + var configContent = await File.ReadAllTextAsync(destConfig); + Assert.Equal("MOD CONFIG", configContent); + + // Original base file remains completely untouched + Assert.Equal("BASE CONFIG", await File.ReadAllTextAsync(baseFilePath)); + } + finally + { + if (Directory.Exists(tempBase)) + { + Directory.Delete(tempBase, true); + } + + if (Directory.Exists(tempUserWorkspace)) + { + Directory.Delete(tempUserWorkspace, true); + } + } + } } + diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs index 1816753e7..6ba5f435f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs @@ -151,16 +151,16 @@ public void Dispose() [Fact] public async Task PrepareWorkspace_WithCasContent_CreatesCorrectLinksAsync() { - // Skip test if running on non-Windows or without admin privileges + // Skip test on Windows if not running with administrator privileges bool isWindows = OperatingSystem.IsWindows(); bool isAdmin = isWindows && new System.Security.Principal.WindowsPrincipal( System.Security.Principal.WindowsIdentity.GetCurrent()) .IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); - if (!isWindows || !isAdmin) + if (isWindows && !isAdmin) { - return; // Skip test on non-Windows or non-admin + return; // Skip test on Windows without admin } // The CAS object should already be verified to exist from constructor diff --git a/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs index 2fe8f370f..91272b280 100644 --- a/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs +++ b/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs @@ -53,16 +53,13 @@ public override long EstimateDiskUsage(WorkspaceConfiguration configuration) return 0; long totalSize = 0; - foreach (var manifest in configuration.Manifests) + foreach (var file in configuration.GetWorkspaceUniqueFiles()) { - foreach (var file in (manifest.Files ?? Enumerable.Empty()).Where(f => f.InstallTarget == ContentInstallTarget.Workspace)) - { - // Prevent negative sizes and overflow - long safeSize = Math.Max(0, file.Size); - if (long.MaxValue - totalSize < safeSize) - return long.MaxValue; // Indicate overflow - totalSize += safeSize; - } + // Prevent negative sizes and overflow + long safeSize = Math.Max(0, file.Size); + if (long.MaxValue - totalSize < safeSize) + return long.MaxValue; // Indicate overflow + totalSize += safeSize; } return totalSize; @@ -96,9 +93,10 @@ public override async Task PrepareAsync( // Create workspace directory Directory.CreateDirectory(workspacePath); + // Deduplicate files by RelativePath with priority ordering (higher priority content wins) // ONLY include files where InstallTarget is Workspace. - var allFiles = configuration.GetWorkspaceUniqueFiles().ToList(); - var totalFiles = allFiles.Count; + var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles(); + var totalFiles = prioritizedFiles.Count; var processedFiles = 0; long totalBytesProcessed = 0; @@ -123,84 +121,64 @@ public override async Task PrepareAsync( degreeOfParallelism = Environment.ProcessorCount * 2; } - // Group files by destination path to handle conflicts - // include files where InstallTarget is Workspace. - var filesByDestination = configuration.Manifests - .SelectMany(m => (m.Files ?? Enumerable.Empty()) - .Where(f => f.InstallTarget == ContentInstallTarget.Workspace) - .Select(f => new { Manifest = m, File = f })) - .GroupBy(item => item.File.RelativePath, StringComparer.OrdinalIgnoreCase) - .ToList(); - await Parallel.ForEachAsync( - filesByDestination, + prioritizedFiles, new ParallelOptions { MaxDegreeOfParallelism = degreeOfParallelism, CancellationToken = cancellationToken, }, - async (fileGroup, ct) => + async (item, ct) => { - // For each destination path, process files in priority order (lowest to highest) - // Priority: GameInstallation (10) < Addon (40) < GameClient (50) < Patch (90) < Mod (100) - // This ensures higher priority content overwrites lower priority - var orderedFiles = fileGroup - .OrderBy(item => ContentTypePriority.GetPriority(item.Manifest.ContentType)) - .ToList(); - - // Process all versions of this file in priority order - // The last one (highest priority) will be the final version - foreach (var item in orderedFiles) - { - var destinationPath = Path.Combine(workspacePath, item.File.RelativePath); + var file = item.File; + var manifest = item.Manifest; + var destinationPath = Path.Combine(workspacePath, file.RelativePath); - try + try + { + if (file.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(file.Hash)) + { + // Use CAS content + await CreateCasLinkAsync(file.Hash, destinationPath, manifest.ContentType, ct); + } + else { - if (item.File.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(item.File.Hash)) + // Resolve source path supporting multi-source installations + var sourcePath = ResolveSourcePath(file, manifest, configuration); + + if (!ValidateSourceFile(sourcePath, file.RelativePath)) { - // Use CAS content - await CreateCasLinkAsync(item.File.Hash, destinationPath, item.Manifest.ContentType, ct); + return; } - else - { - // Resolve source path supporting multi-source installations - var sourcePath = ResolveSourcePath(item.File, item.Manifest, configuration); - - if (!ValidateSourceFile(sourcePath, item.File.RelativePath)) - { - continue; - } - await FileOperations.CopyFileAsync(sourcePath, destinationPath, ct); + await FileOperations.CopyFileAsync(sourcePath, destinationPath, ct); - // Verify file integrity if hash is provided - if (!string.IsNullOrEmpty(item.File.Hash)) + // Verify file integrity if hash is provided + if (!string.IsNullOrEmpty(file.Hash)) + { + var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, file.Hash, ct); + if (!hashValid) { - var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, item.File.Hash, ct); - if (!hashValid) - { - Logger.LogWarning("Hash verification failed for file: {RelativePath}", item.File.RelativePath); - } + Logger.LogWarning("Hash verification failed for file: {RelativePath}", file.RelativePath); } } } - catch (Exception ex) + + Interlocked.Add(ref totalBytesProcessed, file.Size); + var current = Interlocked.Increment(ref processedFiles); + if (current % 50 == 0 || current == totalFiles) { - Logger.LogError( - ex, - "Failed to copy file {RelativePath} to {DestinationPath}", - item.File.RelativePath, - destinationPath); - throw new InvalidOperationException($"Failed to copy file {item.File.RelativePath}: {ex.Message}", ex); + ReportProgress(progress, current, totalFiles, "Copying files", file.RelativePath); } } - - // Only count the file group once for progress reporting - Interlocked.Add(ref totalBytesProcessed, orderedFiles.First().File.Size); - var current = Interlocked.Increment(ref processedFiles); - if (current % 50 == 0 || current == totalFiles) + catch (Exception ex) { - ReportProgress(progress, current, totalFiles, "Copying files", orderedFiles.First().File.RelativePath); + Logger.LogError( + ex, + "Failed to copy file {RelativePath} to {DestinationPath}", + file.RelativePath, + destinationPath); + throw new InvalidOperationException($"Failed to copy file {file.RelativePath}: {ex.Message}", ex); } }); diff --git a/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs index 5edee62ba..978b51fbc 100644 --- a/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs +++ b/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs @@ -80,19 +80,9 @@ public override async Task PrepareAsync( // Create workspace directory Directory.CreateDirectory(workspacePath); - // Deduplicate files by RelativePath with priority ordering (GameClient > GameInstallation) - // so lower-priority sources cannot overwrite higher-priority files like modded clients. + // Deduplicate files by RelativePath with priority ordering (higher priority content wins) // ONLY include files where InstallTarget is Workspace. - var prioritizedFiles = configuration.Manifests - .SelectMany((manifest, index) => (manifest.Files ?? Enumerable.Empty()) - .Where(f => f.InstallTarget == ContentInstallTarget.Workspace) - .Select(file => new { File = file, Manifest = manifest, ManifestIndex = index })) - .GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase) - .Select(g => g - .OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType)) - .ThenByDescending(x => x.ManifestIndex) // deterministic tie-breaker - .First()) - .ToList(); + var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles(); var totalFiles = prioritizedFiles.Count; var processedFiles = 0; @@ -107,12 +97,9 @@ public override async Task PrepareAsync( Logger.LogDebug("Processing {TotalFiles} files (prioritized by content type)", totalFiles); ReportProgress(progress, 0, totalFiles, "Initializing", string.Empty); - foreach (var prioritized in prioritizedFiles) + foreach (var (file, manifest) in prioritizedFiles) { cancellationToken.ThrowIfCancellationRequested(); - - var manifest = prioritized.Manifest; - var file = prioritized.File; var destinationPath = Path.Combine(workspacePath, file.RelativePath); try diff --git a/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs index d816c130d..3c3197910 100644 --- a/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs +++ b/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs @@ -48,18 +48,15 @@ public override long EstimateDiskUsage(WorkspaceConfiguration configuration) return 0; long totalUsage = 0; - foreach (var manifest in configuration.Manifests) + foreach (var file in configuration.GetWorkspaceUniqueFiles()) { - foreach (var file in manifest.Files) + if (IsEssentialFile(file.RelativePath, file.Size)) { - if (IsEssentialFile(file.RelativePath, file.Size)) - { - totalUsage += file.Size; - } - else - { - totalUsage += LinkOverheadBytes; - } + totalUsage += file.Size; + } + else + { + totalUsage += LinkOverheadBytes; } } @@ -91,10 +88,10 @@ public override async Task PrepareAsync( // Create workspace directory Directory.CreateDirectory(workspacePath); - // Deduplicate files by RelativePath - multiple manifests may contain the same file - // include files where InstallTarget is Workspace. - var allFiles = configuration.GetWorkspaceUniqueFiles().ToList(); - var totalFiles = allFiles.Count; + // Deduplicate files by RelativePath with priority ordering (higher priority content wins) + // ONLY include files where InstallTarget is Workspace. + var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles(); + var totalFiles = prioritizedFiles.Count; var processedFiles = 0; long totalBytesProcessed = 0; var estimatedTotalBytes = EstimateDiskUsage(configuration); @@ -103,7 +100,7 @@ public override async Task PrepareAsync( Logger.LogDebug("Processing {TotalFiles} files with estimated size {EstimatedSize} bytes", totalFiles, estimatedTotalBytes); // Pre-classify files for reporting - var essentialCount = allFiles.Count(f => IsEssentialFile(f.RelativePath, f.Size)); + var essentialCount = prioritizedFiles.Count(p => IsEssentialFile(p.File.RelativePath, p.File.Size)); var nonEssentialCount = totalFiles - essentialCount; Logger.LogDebug( "Classified {EssentialCount} essential files (will copy) and {NonEssentialCount} non-essential files (will symlink)", @@ -111,25 +108,38 @@ public override async Task PrepareAsync( nonEssentialCount); ReportProgress(progress, 0, totalFiles, "Initializing", string.Empty); - // Process each manifest and its files to maintain manifest context for source path resolution - foreach (var manifest in configuration.Manifests) + // Process prioritized files maintaining winning manifest context for source path resolution + foreach (var (file, manifest) in prioritizedFiles) { - foreach (var file in (manifest.Files ?? Enumerable.Empty()).Where(f => f.InstallTarget == ContentInstallTarget.Workspace)) - { - cancellationToken.ThrowIfCancellationRequested(); - var destinationPath = Path.Combine(workspacePath, file.RelativePath); - var isEssential = IsEssentialFile(file.RelativePath, file.Size); + cancellationToken.ThrowIfCancellationRequested(); + var destinationPath = Path.Combine(workspacePath, file.RelativePath); + var isEssential = IsEssentialFile(file.RelativePath, file.Size); - try + try + { + if (file.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(file.Hash)) { - if (file.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(file.Hash)) + if (isEssential) + { + var success = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken); + if (!success) + { + throw new InvalidOperationException($"Failed to copy essential file from CAS: {file.RelativePath} (Hash: {file.Hash})"); + } + + copiedFiles++; + totalBytesProcessed += file.Size; + } + else { - if (isEssential) + var success = await FileOperations.LinkFromCasAsync(file.Hash, destinationPath, useHardLink: false, contentType: manifest.ContentType, cancellationToken: cancellationToken); + if (!success) { - var success = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken); - if (!success) + Logger.LogWarning("CAS Link failed for {RelativePath}, attempting copy from CAS", file.RelativePath); + var copySuccess = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken); + if (!copySuccess) { - throw new InvalidOperationException($"Failed to copy essential file from CAS: {file.RelativePath} (Hash: {file.Hash})"); + throw new InvalidOperationException($"Failed to link or copy file from CAS: {file.RelativePath} (Hash: {file.Hash})"); } copiedFiles++; @@ -137,94 +147,78 @@ public override async Task PrepareAsync( } else { - var success = await FileOperations.LinkFromCasAsync(file.Hash, destinationPath, useHardLink: false, contentType: manifest.ContentType, cancellationToken: cancellationToken); - if (!success) - { - Logger.LogWarning("CAS Link failed for {RelativePath}, attempting copy from CAS", file.RelativePath); - var copySuccess = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken); - if (!copySuccess) - { - throw new InvalidOperationException($"Failed to link or copy file from CAS: {file.RelativePath} (Hash: {file.Hash})"); - } + symlinkedFiles++; + totalBytesProcessed += LinkOverheadBytes; + } + } + } + else + { + // Resolve source path supporting multi-source installations + var sourcePath = ResolveSourcePath(file, manifest, configuration); + if (!ValidateSourceFile(sourcePath, file.RelativePath)) + { + continue; + } - copiedFiles++; - totalBytesProcessed += file.Size; - } - else + if (isEssential) + { + await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken); + copiedFiles++; + totalBytesProcessed += file.Size; + if (!string.IsNullOrEmpty(file.Hash)) + { + var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, file.Hash, cancellationToken); + if (!hashValid) { - symlinkedFiles++; - totalBytesProcessed += LinkOverheadBytes; + throw new InvalidOperationException($"Hash verification failed for essential file: {file.RelativePath}"); } } } else { - // Resolve source path supporting multi-source installations - var sourcePath = ResolveSourcePath(file, manifest, configuration); - if (!ValidateSourceFile(sourcePath, file.RelativePath)) + try { - continue; - } - - if (isEssential) - { - await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken); - copiedFiles++; - totalBytesProcessed += file.Size; - if (!string.IsNullOrEmpty(file.Hash)) - { - var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, file.Hash, cancellationToken); - if (!hashValid) - { - throw new InvalidOperationException($"Hash verification failed for essential file: {file.RelativePath}"); - } - } + await FileOperations.CreateSymlinkAsync(destinationPath, sourcePath, allowFallback: false, cancellationToken); + symlinkedFiles++; + totalBytesProcessed += LinkOverheadBytes; } - else + catch (UnauthorizedAccessException) when (FileOperationsService.AreSameVolume(sourcePath, destinationPath)) { + // Fall back to hardlink on same volume when symlink fails due to lack of admin rights + Logger.LogWarning("Symlink creation failed (no admin rights), falling back to hardlink for {RelativePath}", file.RelativePath); try { - await FileOperations.CreateSymlinkAsync(destinationPath, sourcePath, allowFallback: false, cancellationToken); - symlinkedFiles++; + await FileOperations.CreateHardLinkAsync(destinationPath, sourcePath, cancellationToken); + symlinkedFiles++; // Still count as symlinked for reporting purposes totalBytesProcessed += LinkOverheadBytes; } - catch (UnauthorizedAccessException) when (FileOperationsService.AreSameVolume(sourcePath, destinationPath)) + catch (Exception hardLinkEx) { - // Fall back to hardlink on same volume when symlink fails due to lack of admin rights - Logger.LogWarning("Symlink creation failed (no admin rights), falling back to hardlink for {RelativePath}", file.RelativePath); - try - { - await FileOperations.CreateHardLinkAsync(destinationPath, sourcePath, cancellationToken); - symlinkedFiles++; // Still count as symlinked for reporting purposes - totalBytesProcessed += LinkOverheadBytes; - } - catch (Exception hardLinkEx) - { - Logger.LogError(hardLinkEx, "Hardlink fallback also failed for {RelativePath}, attempting copy", file.RelativePath); - await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken); - copiedFiles++; - totalBytesProcessed += file.Size; - } + Logger.LogError(hardLinkEx, "Hardlink fallback also failed for {RelativePath}, attempting copy", file.RelativePath); + await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken); + copiedFiles++; + totalBytesProcessed += file.Size; } } } } - catch (Exception ex) - { - var operation = isEssential ? "copy" : "create symlink for"; - Logger.LogError( - ex, - "Failed to {Operation} file {RelativePath} to {DestinationPath}", - operation, - file.RelativePath, - destinationPath); - throw new InvalidOperationException($"Failed to {operation} file {file.RelativePath}: {ex.Message}", ex); - } - - processedFiles++; - var currentOperation = isEssential ? "Copying essential file" : "Creating symlink"; - ReportProgress(progress, processedFiles, totalFiles, currentOperation, file.RelativePath); } + catch (Exception ex) + { + var operation = isEssential ? "copy" : "create symlink for"; + Logger.LogError( + ex, + "Failed to {Operation} file {RelativePath} to {DestinationPath}", + operation, + file.RelativePath, + destinationPath); + throw new InvalidOperationException($"Failed to {operation} file {file.RelativePath}: {ex.Message}", ex); + } + + processedFiles++; + var currentOperation = isEssential ? "Copying essential file" : "Creating symlink"; + ReportProgress(progress, processedFiles, totalFiles, currentOperation, file.RelativePath); } UpdateWorkspaceInfo(workspaceInfo, processedFiles, totalBytesProcessed, configuration); diff --git a/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs index e1a8e68b0..d8c11cd5b 100644 --- a/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs +++ b/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs @@ -44,8 +44,11 @@ public override bool CanHandle(WorkspaceConfiguration configuration) /// public override long EstimateDiskUsage(WorkspaceConfiguration configuration) { + if (configuration?.Manifests is null || configuration.Manifests.Count == 0) + return 0; + // Symbolic links use minimal space - approximate 1KB per link for metadata - return configuration.Manifests.SelectMany(m => m.Files).Count() * LinkOverheadBytes; + return configuration.GetWorkspaceUniqueFiles().Count() * LinkOverheadBytes; } /// @@ -80,8 +83,10 @@ public override async Task PrepareAsync( // Create workspace directory Directory.CreateDirectory(workspacePath); - var allFiles = configuration.Manifests.SelectMany(m => m.Files).ToList(); - var totalFiles = allFiles.Count; + // Deduplicate files by RelativePath with priority ordering (higher priority content wins) + // ONLY include files where InstallTarget is Workspace. + var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles(); + var totalFiles = prioritizedFiles.Count; var processedFiles = 0; Logger.LogDebug("Processing {TotalFiles} files in parallel", totalFiles); @@ -105,16 +110,8 @@ public override async Task PrepareAsync( degreeOfParallelism = Environment.ProcessorCount * 2; } - // Deduplicate files by RelativePath - multiple manifests may contain the same file - // (e.g., GameClient and GameInstallation both contain the executable) - // Group by path and take the first occurrence to avoid parallel creation conflicts - // include files where InstallTarget is Workspace. - var manifestFiles = configuration.GetWorkspaceUniqueFiles() - .Select(f => new { Manifest = configuration.Manifests.First(m => m.Files.Contains(f)), File = f }) - .ToList(); - await Parallel.ForEachAsync( - manifestFiles, + prioritizedFiles, new ParallelOptions { MaxDegreeOfParallelism = degreeOfParallelism,