From 5cb962b6805882c73521224abf4c9d1955386034 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 01:38:39 +0000 Subject: [PATCH 1/4] fix(userdata): resolve file conflict when prior owner profile is deactivated - Ensure CheckFileConflictAsync and CheckFileConflictUnlockedAsync verify if the mapped installation manifest is active before reporting a conflict. - Clean up stale index mappings when the prior owning manifest is deactivated or missing, and propagate cancellation exceptions. - Propagate user data installation results in ProfileContentLinkerService and unify file filtering via GetUserDataFiles. - Add unit tests verifying conflict resolution after profile deactivation, active ownership retention, and index persistence. --- .gitignore | 11 ++ .../UserData/UserDataTrackerServiceTests.cs | 152 ++++++++++++++++++ .../GameInstallationValidatorTests.cs | 5 +- .../Storage/Services/CasPoolManager.cs | 22 +-- .../Services/ProfileContentLinkerService.cs | 107 +++++++++--- .../Services/UserDataTrackerService.cs | 44 +++-- 6 files changed, 293 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 395b2bb14..7237ca5d8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ docs/.vitepress/dist/ docs/.vitepress/cache/ # Build results +build.lock [Dd]ebug/ [Rr]elease/ @@ -173,3 +174,13 @@ _NCrunch* # Velopack releases releases/ Releases/ +.gitnexus + +# SampleCatalogs: machine-specific generated shortcuts. Never commit them. +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.reg +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.desktop +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.app/ +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.url +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.desktop +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.command +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.webloc diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs index ae8e591cd..f754ef55a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; @@ -10,6 +11,7 @@ using GenHub.Core.Interfaces.Workspace; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.UserData; using GenHub.Features.UserData.Services; using Microsoft.Extensions.Logging; using Moq; @@ -824,4 +826,154 @@ public async Task InstallUserDataAsync_WhenBackupFails_AbortsInstallationToPreve Assert.Contains("Failed to create safety backup", result.FirstError, StringComparison.OrdinalIgnoreCase); } } + + /// + /// Verifies that when a profile is deactivated, another profile can install the same user data files without encountering a conflict. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task InstallUserDataAsync_WhenPriorOwnerProfileIsDeactivated_SucceedsWithoutConflictAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/Arabia v2/AdrianeMapSettings.ini", + Hash = "hash-map-settings", + Size = 500, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + // 1. Profile A installs the map pack + var installA = await _trackerService.InstallUserDataAsync( + "mappack-id", + "profile-a", + GameType.ZeroHour, + files, + "1.0", + "Map Pack", + CancellationToken.None); + + Assert.True(installA.Success); + + // 2. Profile A is deactivated + var deactivateA = await _trackerService.DeactivateProfileUserDataAsync("profile-a", CancellationToken.None); + Assert.True(deactivateA.Success); + + // 3. Profile B installs the same map pack + var installB = await _trackerService.InstallUserDataAsync( + "mappack-id", + "profile-b", + GameType.ZeroHour, + files, + "1.0", + "Map Pack", + CancellationToken.None); + + // Assert: Installation succeeds for profile B and ownership transfers + Assert.True(installB.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "Arabia v2", "AdrianeMapSettings.ini"); + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + Assert.True(conflictResult.Success); + Assert.Equal("mappack-id_profile-b", conflictResult.Data); + } + + /// + /// Verifies that when a file is temporarily missing on disk but its manifest is active, conflict checking still reports conflict. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CheckFileConflictAsync_WhenFileMissingOnDiskButManifestActive_ReportsConflictAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/TempMissing/map.ini", + Hash = "hash-missing-test", + Size = 100, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + var installResult = await _trackerService.InstallUserDataAsync( + "missing-test-manifest", + "profile-missing-test", + GameType.ZeroHour, + files, + "1.0", + "Missing Test", + CancellationToken.None); + + Assert.True(installResult.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "TempMissing", "map.ini"); + Assert.True(File.Exists(targetPath)); + + // Temporarily delete the file from disk + File.Delete(targetPath); + Assert.False(File.Exists(targetPath)); + + // Act + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + + // Assert: Conflict is still reported because the owning manifest is active + Assert.True(conflictResult.Success); + Assert.Equal("missing-test-manifest_profile-missing-test", conflictResult.Data); + } + + /// + /// Verifies that when a manifest is deactivated, CheckFileConflictAsync prunes the stale mapping and reports no conflict. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CheckFileConflictAsync_WhenManifestDeactivated_PrunesStaleMappingAndReturnsNoConflictAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/DeactivatedCheck/map.ini", + Hash = "hash-deact-test", + Size = 100, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + var installResult = await _trackerService.InstallUserDataAsync( + "deact-test-manifest", + "profile-deact-test", + GameType.ZeroHour, + files, + "1.0", + "Deact Test", + CancellationToken.None); + + Assert.True(installResult.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "DeactivatedCheck", "map.ini"); + + // Deactivate the profile + var deactivateResult = await _trackerService.DeactivateProfileUserDataAsync("profile-deact-test", CancellationToken.None); + Assert.True(deactivateResult.Success); + + // Act + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + + // Assert: No conflict reported and stale mapping is pruned + Assert.True(conflictResult.Success); + Assert.Null(conflictResult.Data); + + // Verify index file persisted on disk no longer maps the path + var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName); + var indexJson = await File.ReadAllTextAsync(indexPath); + var index = JsonSerializer.Deserialize(indexJson); + Assert.NotNull(index); + Assert.False(index.FileToInstallationMap.ContainsKey(Path.GetFullPath(targetPath))); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs index 1e9085592..bdb971a03 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs @@ -96,7 +96,8 @@ public async Task ValidateAsync_WithProgressCallback_ReportsProgressAsync() Assert.True(reportsList.Count > 0, "Expected progress reports to be generated"); // Find the final progress report (highest processed count) - var finalProgress = reportsList.MaxBy(p => p.Processed)!; + var finalProgress = reportsList.MaxBy(p => p.Processed); + Assert.NotNull(finalProgress); // Verify the final progress shows completion Assert.Equal(finalProgress.Total, finalProgress.Processed); @@ -390,7 +391,7 @@ public async Task ValidateAsync_ContentValidatorException_HandlesGracefullyAsync /// /// Custom progress implementation that captures reports synchronously. /// - private class SynchronousProgress : IProgress + private sealed class SynchronousProgress : IProgress { private readonly List _reports = new(); private readonly object _lock = new(); diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index 03d643eff..e839380da 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -174,6 +174,17 @@ private static string NormalizeRoot(string? rootPath) : Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); } + private static bool IsInsideApplicationDirectory(string rootPath) + { + var appBaseDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory)); + var normalizedRootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); + + return normalizedRootPath.Equals(appBaseDirectory, PathHelper.PathComparison) || + normalizedRootPath.StartsWith( + appBaseDirectory + Path.DirectorySeparatorChar, + PathHelper.PathComparison); + } + private void InitializePool(CasPoolType poolType) { // Double-check locking to ensure thread safety @@ -306,15 +317,4 @@ private void RefreshLegacyInstallationPool(string activeInstallationRoot) string.Join(", ", retainedRoots)); } } - - private bool IsInsideApplicationDirectory(string rootPath) - { - var appBaseDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory)); - var normalizedRootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); - - return normalizedRootPath.Equals(appBaseDirectory, PathHelper.PathComparison) || - normalizedRootPath.StartsWith( - appBaseDirectory + Path.DirectorySeparatorChar, - PathHelper.PathComparison); - } } diff --git a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs index 365c98a1f..5b748757d 100644 --- a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs @@ -9,6 +9,7 @@ using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Core.Models.UserData; using Microsoft.Extensions.Logging; namespace GenHub.Features.UserData.Services; @@ -26,6 +27,43 @@ public class ProfileContentLinkerService( private string? _activeProfileId; + private static bool HasProfileUserData(ContentManifest manifest) + { + return GetUserDataFiles(manifest).Count > 0; + } + + private static IReadOnlyList GetUserDataFiles(ContentManifest manifest) + { + return manifest.Files + .Where(file => file.InstallTarget != ContentInstallTarget.System && + (file.InstallTarget != ContentInstallTarget.Workspace || + manifest.ContentType is ContentType.Map or ContentType.MapPack)) + .Select(file => (manifest.ContentType is ContentType.Map or ContentType.MapPack) && + file.InstallTarget == ContentInstallTarget.Workspace + ? CreateUserMapsFile(file) + : file) + .ToList(); + } + + private static ManifestFile CreateUserMapsFile(ManifestFile file) + { + return new ManifestFile + { + RelativePath = file.RelativePath, + SourceType = file.SourceType, + InstallTarget = ContentInstallTarget.UserMapsDirectory, + Size = file.Size, + Hash = file.Hash, + Permissions = file.Permissions, + IsExecutable = file.IsExecutable, + DownloadUrl = file.DownloadUrl, + IsRequired = file.IsRequired, + SourcePath = file.SourcePath, + PatchSourceFile = file.PatchSourceFile, + PackageInfo = file.PackageInfo, + }; + } + /// public async Task> PrepareProfileUserDataAsync( string profileId, @@ -39,9 +77,7 @@ public async Task> PrepareProfileUserDataAsync( { // Filter to manifests with user data files var userDataManifests = manifests - .Where(m => m.Files.Any(f => - f.InstallTarget != ContentInstallTarget.Workspace && - f.InstallTarget != ContentInstallTarget.System)) + .Where(HasProfileUserData) .ToList(); if (userDataManifests.Count == 0) @@ -89,7 +125,11 @@ public async Task> PrepareProfileUserDataAsync( return OperationResult.CreateFailure(uninstallResult.Errors); } - await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken); + var reinstallResult = await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken); + if (!reinstallResult.Success) + { + return OperationResult.CreateFailure(reinstallResult); + } } else if (!existingResult.Data.IsActive) { @@ -99,7 +139,11 @@ public async Task> PrepareProfileUserDataAsync( else { // New installation needed - await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken); + var installResult = await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken); + if (!installResult.Success) + { + return OperationResult.CreateFailure(installResult); + } } } @@ -120,6 +164,10 @@ public async Task> PrepareProfileUserDataAsync( logger.LogInformation("[ProfileContentLinker] Successfully prepared user data for profile {ProfileId}", profileId); return OperationResult.CreateSuccess(true); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[ProfileContentLinker] Failed to prepare user data for profile {ProfileId}", profileId); @@ -180,7 +228,7 @@ public async Task> SwitchProfileUserDataAsync( // Register this manifest's files for the new profile as well // This ensures they are tracked and won't be deleted when switching FROM the new profile later - await userDataTracker.InstallUserDataAsync( + var adoptResult = await userDataTracker.InstallUserDataAsync( manifest.ManifestId, newProfileId, targetGame, @@ -194,6 +242,11 @@ await userDataTracker.InstallUserDataAsync( manifest.ManifestVersion, manifest.ManifestName, cancellationToken); + + if (!adoptResult.Success) + { + logger.LogWarning("[ProfileContentLinker] Failed to adopt manifest {ManifestId} for profile {ProfileId}: {Error}", manifest.ManifestId, newProfileId, adoptResult.FirstError); + } } } } @@ -201,6 +254,10 @@ await userDataTracker.InstallUserDataAsync( // Prepare new profile's user data return await PrepareProfileUserDataAsync(newProfileId, newManifests, targetGame, cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[ProfileContentLinker] Failed to switch user data to profile {ProfileId}", newProfileId); @@ -230,6 +287,10 @@ public async Task> CleanupDeletedProfileAsync( var cleanupResult = await userDataTracker.CleanupProfileAsync(profileId, cancellationToken); return cleanupResult; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[ProfileContentLinker] Failed to cleanup profile {ProfileId}", profileId); @@ -257,9 +318,7 @@ public async Task> UpdateProfileUserDataAsync( // Filter to manifests with user data var userDataManifests = newManifests - .Where(m => m.Files.Any(f => - f.InstallTarget != ContentInstallTarget.Workspace && - f.InstallTarget != ContentInstallTarget.System)) + .Where(HasProfileUserData) .ToList(); var newManifestIds = userDataManifests.Select(m => m.Id.Value).ToHashSet(); @@ -286,11 +345,15 @@ public async Task> UpdateProfileUserDataAsync( foreach (var manifest in toAdd) { logger.LogInformation("[ProfileContentLinker] Installing new content: {ManifestId}", manifest.Id.Value); - await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken); + var installResult = await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken); + if (!installResult.Success) + { + return OperationResult.CreateFailure(installResult); + } } // Activate if this is the active profile - bool shouldActivate; + bool shouldActivate = false; lock (_activeProfileLock) { shouldActivate = _activeProfileId == profileId; @@ -315,6 +378,10 @@ public async Task> UpdateProfileUserDataAsync( ? OperationResult.CreateFailure(uninstallErrors) : OperationResult.CreateSuccess(true); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[ProfileContentLinker] Failed to update profile {ProfileId}", profileId); @@ -345,21 +412,23 @@ public bool IsProfileActive(string profileId) /// /// Installs user data files from a manifest for a specific profile. /// - /// A task representing the asynchronous installation operation. - private async Task InstallManifestUserDataAsync( + /// An operation result containing the installed user data manifest. + private async Task> InstallManifestUserDataAsync( ContentManifest manifest, string profileId, GameType targetGame, CancellationToken cancellationToken) { - var userDataFiles = manifest.Files - .Where(f => f.InstallTarget != ContentInstallTarget.Workspace && - f.InstallTarget != ContentInstallTarget.System) - .ToList(); + var userDataFiles = GetUserDataFiles(manifest); if (userDataFiles.Count == 0) { - return; + return OperationResult.CreateSuccess(new UserDataManifest + { + ManifestId = manifest.Id.Value, + ProfileId = profileId, + TargetGame = targetGame, + }); } logger.LogDebug( @@ -367,7 +436,7 @@ private async Task InstallManifestUserDataAsync( userDataFiles.Count, manifest.Id.Value); - await userDataTracker.InstallUserDataAsync( + return await userDataTracker.InstallUserDataAsync( manifest.Id.Value, profileId, targetGame, diff --git a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs index 1a5301ba6..4ad1caf83 100644 --- a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs @@ -469,7 +469,7 @@ public async Task>> GetGameUserD foreach (var file in manifestFiles) { var manifest = await LoadUserDataManifestFromFileAsync(file, cancellationToken); - if (manifest is { TargetGame: var manifestGame } && manifestGame == targetGame) + if (manifest?.TargetGame == targetGame) { manifests.Add(manifest); } @@ -538,8 +538,7 @@ public async Task> VerifyInstallationAsync( continue; } - if (!file.IsHardLink && - !await fileOperations.VerifyFileHashAsync(file.AbsolutePath, file.SourceHash, cancellationToken)) + if (!file.IsHardLink && !await fileOperations.VerifyFileHashAsync(file.AbsolutePath, file.SourceHash, cancellationToken)) { logger.LogWarning("[UserData] File hash mismatch: {Path}", file.AbsolutePath); allValid = false; @@ -563,22 +562,14 @@ public async Task> VerifyInstallationAsync( string absolutePath, CancellationToken cancellationToken = default) { + await IndexLock.WaitAsync(cancellationToken); try { - var index = await LoadIndexAsync(cancellationToken); - var normalizedPath = Path.GetFullPath(absolutePath); - - if (index.FileToInstallationMap.TryGetValue(normalizedPath, out var installationKey)) - { - return OperationResult.CreateSuccess(installationKey); - } - - return OperationResult.CreateSuccess(null); + return await CheckFileConflictUnlockedAsync(absolutePath, cancellationToken); } - catch (Exception ex) + finally { - logger.LogError(ex, "[UserData] Failed to check file conflict for {Path}", absolutePath); - return OperationResult.CreateFailure($"Failed to check file conflict: {ex.Message}"); + IndexLock.Release(); } } @@ -1637,11 +1628,32 @@ private async Task SaveIndexAsync(UserDataIndex index, CancellationToken cancell if (index.FileToInstallationMap.TryGetValue(normalizedPath, out var installationKey)) { - return OperationResult.CreateSuccess(installationKey); + var manifest = await LoadUserDataManifestByKeyAsync(installationKey, cancellationToken); + if (manifest != null && manifest.IsActive) + { + return OperationResult.CreateSuccess(installationKey); + } + + var manifestFilePath = GetManifestFilePath(installationKey); + if (!File.Exists(manifestFilePath) || (manifest != null && !manifest.IsActive)) + { + // Installation is inactive or manifest no longer exists; clean up stale index mapping and persist + index.FileToInstallationMap.Remove(normalizedPath); + await SaveIndexAsync(index, cancellationToken); + } + else + { + // Manifest file exists on disk but could not be read; retain conflict conservatively + return OperationResult.CreateSuccess(installationKey); + } } return OperationResult.CreateSuccess(null); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[UserData] Failed to check file conflict for {Path}", absolutePath); From cc4911603d3fb3e4f3fbc86ccfa89136c2f31097 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 02:33:33 +0000 Subject: [PATCH 2/4] fix(userdata): order public methods before private helpers in ProfileContentLinkerService --- .../Services/ProfileContentLinkerService.cs | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs index 5b748757d..68f89cdcd 100644 --- a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs @@ -27,43 +27,6 @@ public class ProfileContentLinkerService( private string? _activeProfileId; - private static bool HasProfileUserData(ContentManifest manifest) - { - return GetUserDataFiles(manifest).Count > 0; - } - - private static IReadOnlyList GetUserDataFiles(ContentManifest manifest) - { - return manifest.Files - .Where(file => file.InstallTarget != ContentInstallTarget.System && - (file.InstallTarget != ContentInstallTarget.Workspace || - manifest.ContentType is ContentType.Map or ContentType.MapPack)) - .Select(file => (manifest.ContentType is ContentType.Map or ContentType.MapPack) && - file.InstallTarget == ContentInstallTarget.Workspace - ? CreateUserMapsFile(file) - : file) - .ToList(); - } - - private static ManifestFile CreateUserMapsFile(ManifestFile file) - { - return new ManifestFile - { - RelativePath = file.RelativePath, - SourceType = file.SourceType, - InstallTarget = ContentInstallTarget.UserMapsDirectory, - Size = file.Size, - Hash = file.Hash, - Permissions = file.Permissions, - IsExecutable = file.IsExecutable, - DownloadUrl = file.DownloadUrl, - IsRequired = file.IsRequired, - SourcePath = file.SourcePath, - PatchSourceFile = file.PatchSourceFile, - PackageInfo = file.PackageInfo, - }; - } - /// public async Task> PrepareProfileUserDataAsync( string profileId, @@ -409,6 +372,43 @@ public bool IsProfileActive(string profileId) } } + private static bool HasProfileUserData(ContentManifest manifest) + { + return GetUserDataFiles(manifest).Count > 0; + } + + private static IReadOnlyList GetUserDataFiles(ContentManifest manifest) + { + return manifest.Files + .Where(file => file.InstallTarget != ContentInstallTarget.System && + (file.InstallTarget != ContentInstallTarget.Workspace || + manifest.ContentType is ContentType.Map or ContentType.MapPack)) + .Select(file => (manifest.ContentType is ContentType.Map or ContentType.MapPack) && + file.InstallTarget == ContentInstallTarget.Workspace + ? CreateUserMapsFile(file) + : file) + .ToList(); + } + + private static ManifestFile CreateUserMapsFile(ManifestFile file) + { + return new ManifestFile + { + RelativePath = file.RelativePath, + SourceType = file.SourceType, + InstallTarget = ContentInstallTarget.UserMapsDirectory, + Size = file.Size, + Hash = file.Hash, + Permissions = file.Permissions, + IsExecutable = file.IsExecutable, + DownloadUrl = file.DownloadUrl, + IsRequired = file.IsRequired, + SourcePath = file.SourcePath, + PatchSourceFile = file.PatchSourceFile, + PackageInfo = file.PackageInfo, + }; + } + /// /// Installs user data files from a manifest for a specific profile. /// From 10653827f968ad9542c8e7bb7b827a93eb62c09e Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 03:04:06 +0000 Subject: [PATCH 3/4] fix(userdata): preserve new owner file and mapping when prior owner is cleaned up --- .../UserData/UserDataTrackerServiceTests.cs | 76 +++++++++++++++++++ .../Services/ProfileContentLinkerService.cs | 7 +- .../Services/UserDataTrackerService.cs | 20 ++++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs index f754ef55a..c452f620d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs @@ -876,9 +876,85 @@ public async Task InstallUserDataAsync_WhenPriorOwnerProfileIsDeactivated_Succee Assert.True(installB.Success); var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "Arabia v2", "AdrianeMapSettings.ini"); + Assert.True(File.Exists(targetPath)); + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); Assert.True(conflictResult.Success); Assert.Equal("mappack-id_profile-b", conflictResult.Data); + + var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName); + var indexJson = await File.ReadAllTextAsync(indexPath); + var index = JsonSerializer.Deserialize(indexJson); + Assert.NotNull(index); + Assert.True(index.FileToInstallationMap.TryGetValue(Path.GetFullPath(targetPath), out var ownerKey)); + Assert.Equal("mappack-id_profile-b", ownerKey); + } + + /// + /// Verifies that cleaning up an uninstalled or old profile does not delete files or prune mappings owned by a newer active profile. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CleanupProfileAsync_WhenPriorOwnerProfileCleanedUpAfterTransfer_PreservesNewOwnerFilesAndIndexMappingAsync() + { + // Arrange + var files = new List + { + new() + { + RelativePath = "Maps/TransferCheck/map.ini", + Hash = "hash-transfer-test", + Size = 300, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + // 1. Profile A installs the map pack + var installA = await _trackerService.InstallUserDataAsync( + "transfer-manifest", + "profile-a", + GameType.ZeroHour, + files, + "1.0", + "Transfer Test", + CancellationToken.None); + Assert.True(installA.Success); + + // 2. Profile A is deactivated + var deactivateA = await _trackerService.DeactivateProfileUserDataAsync("profile-a", CancellationToken.None); + Assert.True(deactivateA.Success); + + // 3. Profile B installs the same map pack + var installB = await _trackerService.InstallUserDataAsync( + "transfer-manifest", + "profile-b", + GameType.ZeroHour, + files, + "1.0", + "Transfer Test", + CancellationToken.None); + Assert.True(installB.Success); + + var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "TransferCheck", "map.ini"); + Assert.True(File.Exists(targetPath)); + + // 4. Profile A is cleaned up + var cleanupA = await _trackerService.CleanupProfileAsync("profile-a", CancellationToken.None); + Assert.True(cleanupA.Success); + + // Assert: Profile B's file and index mapping remain intact + Assert.True(File.Exists(targetPath)); + + var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None); + Assert.True(conflictResult.Success); + Assert.Equal("transfer-manifest_profile-b", conflictResult.Data); + + var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName); + var indexJson = await File.ReadAllTextAsync(indexPath); + var index = JsonSerializer.Deserialize(indexJson); + Assert.NotNull(index); + Assert.True(index.FileToInstallationMap.TryGetValue(Path.GetFullPath(targetPath), out var ownerKey)); + Assert.Equal("transfer-manifest_profile-b", ownerKey); } /// diff --git a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs index 68f89cdcd..19899f1f8 100644 --- a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs @@ -423,12 +423,7 @@ private async Task> InstallManifestUserDataAsy if (userDataFiles.Count == 0) { - return OperationResult.CreateSuccess(new UserDataManifest - { - ManifestId = manifest.Id.Value, - ProfileId = profileId, - TargetGame = targetGame, - }); + return OperationResult.CreateFailure("No user data files to install"); } logger.LogDebug( diff --git a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs index 4ad1caf83..2efd13b87 100644 --- a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs @@ -1370,11 +1370,23 @@ private async Task CleanupInstalledFilesAsync(UserDataManifest manifest, C { var userDataBasePath = GetUserDataBasePath(manifest.TargetGame); var allBackupsRestored = true; + var index = await LoadIndexUnlockedAsync(cancellationToken); foreach (var file in manifest.InstalledFiles) { cancellationToken.ThrowIfCancellationRequested(); + if (index.FileToInstallationMap.TryGetValue(file.AbsolutePath, out var currentOwnerKey) && + currentOwnerKey != manifest.InstallationKey) + { + logger.LogDebug( + "[UserData] Skipping cleanup of {Path} for installation {Key}; currently owned by {OwnerKey}", + file.AbsolutePath, + manifest.InstallationKey, + currentOwnerKey); + continue; + } + var hasBackup = !string.IsNullOrEmpty(file.BackupPath) && File.Exists(file.BackupPath); var backupRestored = false; @@ -1720,10 +1732,14 @@ private async Task UpdateIndexUnlockedAsync(UserDataManifest manifest, bool isAd { index.InstallationKeys.Remove(key); - // Remove file mappings + // Remove file mappings only if still mapped to this installation foreach (var file in manifest.InstalledFiles) { - index.FileToInstallationMap.Remove(file.AbsolutePath); + if (index.FileToInstallationMap.TryGetValue(file.AbsolutePath, out var mappedKey) && + mappedKey == key) + { + index.FileToInstallationMap.Remove(file.AbsolutePath); + } } // Remove from profile mappings From 7ca7c9bca3072bc782721c4b3b36cd53a26aff76 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 03:11:53 +0000 Subject: [PATCH 4/4] fix(userdata): simplify file mapping loops using linq select in UserDataTrackerService --- .../UserData/Services/UserDataTrackerService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs index 2efd13b87..1c53d4658 100644 --- a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs @@ -1699,9 +1699,9 @@ private async Task UpdateIndexUnlockedAsync(UserDataManifest manifest, bool isAd } // Update file mappings - foreach (var file in manifest.InstalledFiles) + foreach (var path in manifest.InstalledFiles.Select(file => file.AbsolutePath)) { - index.FileToInstallationMap[file.AbsolutePath] = key; + index.FileToInstallationMap[path] = key; } // Update profile mappings @@ -1733,12 +1733,12 @@ private async Task UpdateIndexUnlockedAsync(UserDataManifest manifest, bool isAd index.InstallationKeys.Remove(key); // Remove file mappings only if still mapped to this installation - foreach (var file in manifest.InstalledFiles) + foreach (var path in manifest.InstalledFiles.Select(file => file.AbsolutePath)) { - if (index.FileToInstallationMap.TryGetValue(file.AbsolutePath, out var mappedKey) && + if (index.FileToInstallationMap.TryGetValue(path, out var mappedKey) && mappedKey == key) { - index.FileToInstallationMap.Remove(file.AbsolutePath); + index.FileToInstallationMap.Remove(path); } }