From f2342b0c7ba4292cec3aff0da3020ad66f57c9a7 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sat, 1 Aug 2026 23:48:04 +0100 Subject: [PATCH 1/9] fix(cas): fall back from an unwritable installation CAS pool --- .../Common/IStorageWritabilityProbe.cs | 20 ++ .../Content/ContentOrchestratorTests.cs | 8 +- .../Storage/CasPoolWritabilityTests.cs | 181 ++++++++++++++++++ .../Services/ConfigurationProviderService.cs | 1 + .../Services/StorageWritabilityProbe.cs | 109 +++++++++++ .../CommunityOutpostDeliverer.cs | 94 +++++---- .../Content/Services/ContentOrchestrator.cs | 78 ++++---- .../Storage/Services/CasPoolManager.cs | 38 ++++ .../Storage/Services/CasPoolResolver.cs | 23 ++- .../DependencyInjection/CasModule.cs | 5 + .../ConfigurationModule.cs | 4 + 11 files changed, 468 insertions(+), 93 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs create mode 100644 GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs diff --git a/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs b/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs new file mode 100644 index 000000000..c407f0748 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs @@ -0,0 +1,20 @@ +namespace GenHub.Core.Interfaces.Common; + +/// +/// Determines whether GenHub can create storage at a filesystem location. +/// +public interface IStorageWritabilityProbe +{ + /// + /// Checks whether a directory can be created at, or files written into, the given path. + /// + /// The storage path to check. + /// true when the location accepts writes; otherwise, false. + bool CanCreateStorageAt(string storagePath); + + /// + /// Discards any cached result for a storage path so the next check probes the filesystem again. + /// + /// The storage path to re-probe, or null to discard every cached result. + void Invalidate(string? storagePath = null); +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index fdbd98d2a..dc59a4c26 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -23,6 +23,7 @@ public class ContentOrchestratorTests private readonly Mock _manifestPoolMock = default!; private readonly Mock _installationServiceMock = default!; private readonly Mock _userSettingsServiceMock = default!; + private readonly Mock _writabilityProbeMock = default!; private readonly Mock> _loggerMock = default!; /// @@ -35,6 +36,7 @@ public ContentOrchestratorTests() _manifestPoolMock = new Mock(); _installationServiceMock = new Mock(); _userSettingsServiceMock = new Mock(); + _writabilityProbeMock = new Mock(); _loggerMock = new Mock>(); } @@ -71,7 +73,8 @@ public async Task SearchAsync_AggregatesResultsFromMultipleProviders_Successfull _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _userSettingsServiceMock.Object); + _userSettingsServiceMock.Object, + _writabilityProbeMock.Object); // Act var result = await orchestrator.SearchAsync(new ContentSearchQuery()); @@ -132,7 +135,8 @@ public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully() _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _userSettingsServiceMock.Object); + _userSettingsServiceMock.Object, + _writabilityProbeMock.Object); // Act var result = await orchestrator.AcquireContentAsync(searchResult); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs new file mode 100644 index 000000000..968bbe454 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs @@ -0,0 +1,181 @@ +using GenHub.Common.Services; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Storage; +using GenHub.Features.Storage.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Storage; + +/// +/// Tests installation CAS pool selection when the pool location cannot be written. +/// +public sealed class CasPoolWritabilityTests : IDisposable +{ + private readonly Mock _userSettingsService = new(); + private readonly Mock _writabilityProbe = new(); + private readonly string _tempPath; + private readonly string _primaryPoolPath; + private readonly string _installationPoolPath; + + /// + /// Initializes a new instance of the class. + /// + public CasPoolWritabilityTests() + { + _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + _primaryPoolPath = Path.Combine(_tempPath, "primary-pool"); + _installationPoolPath = Path.Combine(_tempPath, "Game", ".genhub-cas"); + Directory.CreateDirectory(_primaryPoolPath); + } + + /// + /// Treats a configured but unwritable installation pool as unavailable. + /// + [Fact] + public void IsInstallationPoolAvailable_WhenPoolIsNotWritable_ReturnsFalse() + { + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(_installationPoolPath)).Returns(false); + var resolver = CreateResolver(_installationPoolPath); + + Assert.False(resolver.IsInstallationPoolAvailable()); + } + + /// + /// Keeps a writable installation pool selected. + /// + [Fact] + public void IsInstallationPoolAvailable_WhenPoolIsWritable_ReturnsTrue() + { + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(_installationPoolPath)).Returns(true); + var resolver = CreateResolver(_installationPoolPath); + + Assert.True(resolver.IsInstallationPoolAvailable()); + } + + /// + /// Routes installation-pool content to the primary pool when the installation pool is unwritable. + /// + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.GameInstallation)] + [InlineData(ContentType.Mod)] + public void ResolvePool_WhenInstallationPoolIsNotWritable_UsesPrimaryPool(ContentType contentType) + { + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(_installationPoolPath)).Returns(false); + var resolver = CreateResolver(_installationPoolPath); + + Assert.Equal(CasPoolType.Primary, resolver.ResolvePool(contentType)); + Assert.Equal(_primaryPoolPath, resolver.GetPoolRootPath(contentType)); + } + + /// + /// Keeps routing installation-pool content to a writable installation pool. + /// + [Fact] + public void ResolvePool_WhenInstallationPoolIsWritable_UsesInstallationPool() + { + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(_installationPoolPath)).Returns(true); + var resolver = CreateResolver(_installationPoolPath); + + Assert.Equal(CasPoolType.Installation, resolver.ResolvePool(ContentType.GameClient)); + Assert.Equal(_installationPoolPath, resolver.GetPoolRootPath(ContentType.GameClient)); + } + + /// + /// Treats an empty installation pool path as unavailable without probing. + /// + [Fact] + public void IsInstallationPoolAvailable_WhenPathIsEmpty_ReturnsFalseWithoutProbing() + { + var resolver = CreateResolver(string.Empty); + + Assert.False(resolver.IsInstallationPoolAvailable()); + _writabilityProbe.Verify(probe => probe.CanCreateStorageAt(It.IsAny()), Times.Never); + } + + /// + /// Probes a real unwritable directory end to end rather than a mocked verdict. + /// + [Fact] + public void StorageWritabilityProbe_WhenDirectoryDeniesWrites_ReturnsFalse() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var lockedPath = Path.Combine(_tempPath, "locked"); + Directory.CreateDirectory(lockedPath); + File.SetUnixFileMode(lockedPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + try + { + var probe = new StorageWritabilityProbe(new Mock>().Object); + + Assert.False(probe.CanCreateStorageAt(Path.Combine(lockedPath, ".genhub-cas"))); + Assert.True(probe.CanCreateStorageAt(Path.Combine(_primaryPoolPath, ".genhub-cas"))); + } + finally + { + File.SetUnixFileMode( + lockedPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + /// + /// Leaves no probe files behind after a successful check. + /// + [Fact] + public void StorageWritabilityProbe_WhenLocationIsWritable_LeavesNoProbeFile() + { + var probe = new StorageWritabilityProbe(new Mock>().Object); + + Assert.True(probe.CanCreateStorageAt(Path.Combine(_primaryPoolPath, ".genhub-cas"))); + Assert.Empty(Directory.GetFiles(_primaryPoolPath, ".genhub-write-probe-*")); + } + + /// + public void Dispose() + { + try + { + Directory.Delete(_tempPath, true); + } + catch (IOException) + { + // Best-effort cleanup for temporary test files. + } + catch (UnauthorizedAccessException) + { + // Best-effort cleanup for temporary test files. + } + + GC.SuppressFinalize(this); + } + + private CasPoolResolver CreateResolver(string installationPoolRootPath) + { + _userSettingsService + .Setup(service => service.Get()) + .Returns(new UserSettings + { + CasConfiguration = new CasConfiguration + { + CasRootPath = _primaryPoolPath, + InstallationPoolRootPath = installationPoolRootPath, + }, + }); + + return new CasPoolResolver( + Options.Create(new CasConfiguration { CasRootPath = _primaryPoolPath }), + _userSettingsService.Object, + _writabilityProbe.Object, + new Mock>().Object); + } +} diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs index 39352c787..9956eb328 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -320,6 +320,7 @@ public CasConfiguration GetCasConfiguration() return new CasConfiguration { CasRootPath = defaultPath, + InstallationPoolRootPath = casConfig.InstallationPoolRootPath, EnableAutomaticGc = casConfig.EnableAutomaticGc, HashAlgorithm = casConfig.HashAlgorithm, GcGracePeriod = casConfig.GcGracePeriod, diff --git a/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs b/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs new file mode 100644 index 000000000..5dfc20239 --- /dev/null +++ b/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Security; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Common; +using Microsoft.Extensions.Logging; + +namespace GenHub.Common.Services; + +/// +/// Determines whether GenHub can create storage at a filesystem location by writing a probe file. +/// +public class StorageWritabilityProbe(ILogger logger) : IStorageWritabilityProbe +{ + private const string ProbeFilePrefix = ".genhub-write-probe-"; + + private readonly ConcurrentDictionary _results = new(PathHelper.PathComparer); + + /// + public bool CanCreateStorageAt(string storagePath) + { + if (string.IsNullOrWhiteSpace(storagePath)) + { + return false; + } + + string fullStoragePath; + + try + { + fullStoragePath = Path.GetFullPath(storagePath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or SecurityException or IOException) + { + logger.LogDebug(ex, "Storage path {StoragePath} could not be resolved", storagePath); + return false; + } + + return _results.GetOrAdd(fullStoragePath, Probe); + } + + /// + public void Invalidate(string? storagePath = null) + { + if (string.IsNullOrWhiteSpace(storagePath)) + { + _results.Clear(); + return; + } + + try + { + _results.TryRemove(Path.GetFullPath(storagePath), out _); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or SecurityException or IOException) + { + logger.LogDebug(ex, "Storage path {StoragePath} could not be resolved for invalidation", storagePath); + } + } + + private bool Probe(string fullStoragePath) + { + string? probePath = null; + + try + { + var probeDirectory = Directory.Exists(fullStoragePath) + ? fullStoragePath + : Path.GetDirectoryName(fullStoragePath); + + if (string.IsNullOrWhiteSpace(probeDirectory) || !Directory.Exists(probeDirectory)) + { + logger.LogDebug("Storage path {StoragePath} has no existing directory to probe", fullStoragePath); + return false; + } + + probePath = Path.Combine(probeDirectory, $"{ProbeFilePrefix}{Guid.NewGuid():N}.tmp"); + using var probe = new FileStream( + probePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + probe.WriteByte(0); + return true; + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or ArgumentException or NotSupportedException or SecurityException) + { + logger.LogDebug(ex, "Storage path {StoragePath} is not writable", fullStoragePath); + return false; + } + finally + { + if (!string.IsNullOrWhiteSpace(probePath) && File.Exists(probePath)) + { + try + { + File.Delete(probePath); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + logger.LogDebug(ex, "Could not remove storage write probe {ProbePath}", probePath); + } + } + } + } +} diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index 2fef1f6fd..fcd18d78d 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -39,6 +39,7 @@ public class CommunityOutpostDeliverer( IUserSettingsService userSettingsService, ICasPoolManager? casPoolManager, CompressedImageToTgaConverter avifConverter, + IStorageWritabilityProbe writabilityProbe, ILogger logger) : IContentDeliverer { @@ -675,66 +676,59 @@ private async Task EnsureInstallationPoolPathAsync(CancellationToken cancellatio return; } - // If only one installation, use it - if (installations.Count == 1) - { - var installation = installations[0]; - var installationPath = GetInstallationPath(installation); - if (!string.IsNullOrEmpty(installationPath)) - { - var casPoolPath = Path.Combine(installationPath, ".genhub-cas"); - logger.LogInformation("Auto-setting InstallationPoolRootPath to single installation: {Path}", casPoolPath); - - var saved = await userSettingsService.TryUpdateAndSaveAsync(s => - { - s.CasConfiguration.InstallationPoolRootPath = casPoolPath; - s.PreferredStorageInstallationId = installation.Id; - s.MarkAsExplicitlySet(nameof(s.CasConfiguration.InstallationPoolRootPath)); - return true; - }); + // If multiple installations, prefer Steam over EA App + var preferredInstallation = installations.Count == 1 + ? installations[0] + : installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.Steam) + ?? installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.EaApp) + ?? installations.FirstOrDefault(); - if (!saved) - { - logger.LogError("Failed to save installation pool path settings for installation {InstallationId}", installation.Id); - } + if (preferredInstallation == null) + { + logger.LogWarning("No valid installation found for CAS pool path resolution"); + return; + } - // Verify the setting was applied - var updatedSettings = userSettingsService.Get(); - logger.LogInformation("Verified InstallationPoolRootPath is now: {Path}", updatedSettings.CasConfiguration.InstallationPoolRootPath); - return; - } + var preferredInstallationPath = GetInstallationPath(preferredInstallation); + if (string.IsNullOrEmpty(preferredInstallationPath)) + { + logger.LogWarning("Preferred installation {InstallationId} has no usable path", preferredInstallation.Id); + return; } - // If multiple installations, prefer Steam over EA App - var preferredInstallation = installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.Steam) - ?? installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.EaApp) - ?? installations.FirstOrDefault(); + var poolPath = Path.Combine(preferredInstallationPath, DirectoryNames.GenHubCasPool); - if (preferredInstallation != null) + // An unwritable pool is not a failure: clearing the path routes this content to the + // primary pool instead, and also repairs a protected path persisted by an earlier run. + var isPoolWritable = writabilityProbe.CanCreateStorageAt(poolPath); + if (isPoolWritable) { - var installationPath = GetInstallationPath(preferredInstallation); - if (!string.IsNullOrEmpty(installationPath)) - { - var casPoolPath = Path.Combine(installationPath, ".genhub-cas"); - logger.LogInformation("Auto-setting InstallationPoolRootPath to preferred installation ({InstallationType}): {Path}", preferredInstallation.InstallationType, casPoolPath); - - await userSettingsService.TryUpdateAndSaveAsync(s => - { - s.CasConfiguration.InstallationPoolRootPath = casPoolPath; - s.PreferredStorageInstallationId = preferredInstallation.Id; - s.MarkAsExplicitlySet(nameof(s.CasConfiguration.InstallationPoolRootPath)); - return true; - }); - - // Verify the setting was applied - var updatedSettings = userSettingsService.Get(); - logger.LogInformation("Verified InstallationPoolRootPath is now: {Path}", updatedSettings.CasConfiguration.InstallationPoolRootPath); - } + logger.LogInformation( + "Auto-setting InstallationPoolRootPath to preferred installation ({InstallationType}): {Path}", + preferredInstallation.InstallationType, + poolPath); } else { - logger.LogWarning("No valid installation found for CAS pool path resolution"); + logger.LogWarning( + "Installation CAS pool {Path} is not writable; content will be stored in the primary pool", + poolPath); } + + var saved = await userSettingsService.TryUpdateAndSaveAsync(s => + { + s.CasConfiguration.InstallationPoolRootPath = isPoolWritable ? poolPath : string.Empty; + s.PreferredStorageInstallationId = preferredInstallation.Id; + return true; + }); + + if (!saved) + { + logger.LogError("Failed to save installation pool path settings for installation {InstallationId}", preferredInstallation.Id); + } + + var updatedSettings = userSettingsService.Get(); + logger.LogInformation("Verified InstallationPoolRootPath is now: {Path}", updatedSettings.CasConfiguration.InstallationPoolRootPath); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs index 8c5474aa6..c79b52100 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs @@ -37,6 +37,7 @@ public class ContentOrchestrator : IContentOrchestrator private readonly IContentManifestPool _manifestPool; private readonly IGameInstallationService _installationService; private readonly IUserSettingsService _userSettingsService; + private readonly IStorageWritabilityProbe _writabilityProbe; private readonly object _providerLock = new(); /// @@ -81,6 +82,7 @@ public class ContentOrchestrator : IContentOrchestrator /// The manifest pool for acquired content. /// The game installation service for detecting installations. /// The user settings service for updating CAS configuration. + /// The probe used to confirm a CAS pool location accepts writes. public ContentOrchestrator( ILogger logger, IEnumerable providers, @@ -90,7 +92,8 @@ public ContentOrchestrator( IContentValidator contentValidator, IContentManifestPool manifestPool, IGameInstallationService installationService, - IUserSettingsService userSettingsService) + IUserSettingsService userSettingsService, + IStorageWritabilityProbe writabilityProbe) { _logger = logger; _providers = [.. providers]; @@ -109,6 +112,7 @@ public ContentOrchestrator( _manifestPool = manifestPool; _installationService = installationService; _userSettingsService = userSettingsService; + _writabilityProbe = writabilityProbe; _logger.LogInformation("ContentOrchestrator initialized with {ProviderCount} providers, {DiscovererCount} discoverers, {ResolverCount} resolvers", _providers.Count, _discoverers.Count, _resolvers.Count); } @@ -736,52 +740,46 @@ private async Task EnsureInstallationPoolPathAsync(CancellationToken cance return false; } - // If only one installation, use it - if (installations.Count == 1) + // If multiple installations, prefer Steam over EA App + // Note: Since we verified installations.Count >= 1, this will never be null + var preferredInstallation = installations.Count == 1 + ? installations[0] + : installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.Steam) + ?? installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.EaApp) + ?? installations.First(); + + var preferredInstallationPath = GetInstallationPath(preferredInstallation); + if (string.IsNullOrEmpty(preferredInstallationPath)) { - var installation = installations[0]; - var installationPath = GetInstallationPath(installation); - if (!string.IsNullOrEmpty(installationPath)) - { - var casPoolPath = Path.Combine(installationPath, ".genhub-cas"); - _logger.LogInformation("Auto-setting InstallationPoolRootPath to single installation: {Path}", casPoolPath); - - return await _userSettingsService.TryUpdateAndSaveAsync(s => - { - s.CasConfiguration.InstallationPoolRootPath = casPoolPath; - s.PreferredStorageInstallationId = installation.Id; - s.MarkAsExplicitlySet(nameof(s.CasConfiguration.InstallationPoolRootPath)); - return true; - }); - } + _logger.LogWarning("Preferred installation {InstallationId} has no usable path", preferredInstallation.Id); + return false; } - // If multiple installations, prefer Steam over EA App - // Note: Since we verified installations.Count >= 1, this will never be null - var preferredInstallation = installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.Steam) - ?? installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.EaApp) - ?? installations.First(); + var casPoolPath = Path.Combine(preferredInstallationPath, DirectoryNames.GenHubCasPool); - if (preferredInstallation != null) + // An unwritable pool is not a failure: clearing the path routes this content to the + // primary pool instead, and also repairs a protected path persisted by an earlier run. + var isPoolWritable = _writabilityProbe.CanCreateStorageAt(casPoolPath); + if (isPoolWritable) { - var installationPath = GetInstallationPath(preferredInstallation); - if (!string.IsNullOrEmpty(installationPath)) - { - var casPoolPath = Path.Combine(installationPath, ".genhub-cas"); - _logger.LogInformation("Auto-setting InstallationPoolRootPath to preferred installation ({InstallationType}): {Path}", preferredInstallation.InstallationType, casPoolPath); - - return await _userSettingsService.TryUpdateAndSaveAsync(s => - { - s.CasConfiguration.InstallationPoolRootPath = casPoolPath; - s.PreferredStorageInstallationId = preferredInstallation.Id; - s.MarkAsExplicitlySet(nameof(s.CasConfiguration.InstallationPoolRootPath)); - return true; - }); - } + _logger.LogInformation( + "Auto-setting InstallationPoolRootPath to preferred installation ({InstallationType}): {Path}", + preferredInstallation.InstallationType, + casPoolPath); + } + else + { + _logger.LogWarning( + "Installation CAS pool {Path} is not writable; content will be stored in the primary pool", + casPoolPath); } - // Should not be reachable given the checks above - return false; + return await _userSettingsService.TryUpdateAndSaveAsync(s => + { + s.CasConfiguration.InstallationPoolRootPath = isPoolWritable ? casPoolPath : string.Empty; + s.PreferredStorageInstallationId = preferredInstallation.Id; + return true; + }); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index 45e0c28c3..64e5e5925 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Enums; @@ -24,6 +25,7 @@ public class CasPoolManager : ICasPoolManager private readonly CasConfiguration _config; private readonly ConcurrentDictionary _storages = new(); private readonly object _initLock = new(); + private string? _installationPoolRoot; /// /// Initializes a new instance of the class. @@ -62,6 +64,11 @@ public CasPoolManager( /// public ICasStorage GetStorage(CasPoolType poolType) { + if (poolType == CasPoolType.Installation) + { + DiscardInstallationPoolIfRootChanged(); + } + if (_storages.TryGetValue(poolType, out var storage)) { _logger.LogDebug("Returning existing {PoolType} pool storage", poolType); @@ -215,7 +222,38 @@ private void InitializePool(CasPoolType poolType) var storage = new CasStorage(poolConfigOptions, storageLogger, _hashProvider); _storages.TryAdd(poolType, storage); + if (poolType == CasPoolType.Installation) + { + _installationPoolRoot = rootPath; + } + _logger.LogInformation("Initialized {PoolType} CAS pool at {RootPath}", poolType, rootPath); } } + + private void DiscardInstallationPoolIfRootChanged() + { + if (!_storages.ContainsKey(CasPoolType.Installation)) + { + return; + } + + var currentRoot = _poolResolver.GetPoolRootPath(CasPoolType.Installation); + if (string.Equals(currentRoot, _installationPoolRoot, PathHelper.PathComparison)) + { + return; + } + + lock (_initLock) + { + if (_storages.TryRemove(CasPoolType.Installation, out _)) + { + _logger.LogInformation( + "Installation CAS pool root changed from {PreviousRoot} to {CurrentRoot}; discarding the cached pool", + _installationPoolRoot, + currentRoot); + _installationPoolRoot = null; + } + } + } } diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs index 0a278bde6..f75dd5555 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Collections.Generic; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Enums; @@ -14,6 +16,7 @@ namespace GenHub.Features.Storage.Services; public class CasPoolResolver( IOptions config, IUserSettingsService userSettingsService, + IStorageWritabilityProbe writabilityProbe, ILogger logger) : ICasPoolResolver { /// @@ -30,6 +33,7 @@ public class CasPoolResolver( ]; private readonly CasConfiguration _config = config.Value; + private readonly ConcurrentDictionary _unwritablePoolsLogged = new(PathHelper.PathComparer); /// public CasPoolType ResolvePool(ContentType contentType) @@ -71,7 +75,24 @@ public string GetPoolRootPath(ContentType contentType) public bool IsInstallationPoolAvailable() { var path = GetInstallationPoolRootPath(); - return !string.IsNullOrWhiteSpace(path); + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + if (writabilityProbe.CanCreateStorageAt(path)) + { + return true; + } + + if (_unwritablePoolsLogged.TryAdd(path, true)) + { + logger.LogWarning( + "Installation CAS pool {PoolPath} is not writable; content will use the primary pool", + path); + } + + return false; } /// diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs index e76e15add..513225691 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs @@ -1,8 +1,10 @@ +using GenHub.Common.Services; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Storage; using GenHub.Features.Storage.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace GenHub.Infrastructure.DependencyInjection; @@ -18,6 +20,9 @@ public static class CasModule /// The service collection for chaining. public static IServiceCollection AddCasServices(this IServiceCollection services) { + // Pool selection depends on whether a pool location accepts writes + services.TryAddSingleton(); + // Pool management services (must be registered first for CasService to use) services.AddSingleton(); services.AddSingleton(); diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs index 8fb2deb3d..f65e3b651 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs @@ -3,6 +3,7 @@ using GenHub.Core.Interfaces.Common; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; namespace GenHub.Infrastructure.DependencyInjection; @@ -47,12 +48,15 @@ public static IServiceCollection AddConfigurationModule(this IServiceCollection bootstrapLoggerFactory.CreateLogger()); services.AddSingleton>(provider => bootstrapLoggerFactory.CreateLogger()); + services.AddSingleton>(provider => + bootstrapLoggerFactory.CreateLogger()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.TryAddSingleton(); services.AddSingleton(); return services; From 7d7098d0591c73be6067019747e8bc0bae4d5a27 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 2 Aug 2026 03:46:12 +0100 Subject: [PATCH 2/9] fix(cas): preserve fallback pool state safely --- .../Interfaces/Storage/ICasPoolResolver.cs | 6 + .../Storage/IInstallationCasPoolService.cs | 19 ++ .../Models/Storage/CasConfiguration.cs | 14 + .../Services/StorageLocationServiceTests.cs | 51 +++- .../Content/ContentOrchestratorTests.cs | 13 +- .../Storage/CasPoolWritabilityTests.cs | 25 +- .../InstallationCasPoolServiceTests.cs | 261 ++++++++++++++++++ .../Services/ConfigurationProviderService.cs | 2 + .../Common/Services/StorageLocationService.cs | 130 ++------- .../Services/StorageWritabilityProbe.cs | 39 ++- .../CommunityOutpostDeliverer.cs | 94 +------ .../Content/Services/ContentOrchestrator.cs | 84 +----- .../Storage/Services/CasPoolManager.cs | 201 ++++++++------ .../Storage/Services/CasPoolResolver.cs | 18 ++ .../Features/Storage/Services/CasService.cs | 33 +++ .../Features/Storage/Services/CasStorage.cs | 1 - .../Services/InstallationCasPoolService.cs | 235 ++++++++++++++++ .../DependencyInjection/CasModule.cs | 3 + 18 files changed, 847 insertions(+), 382 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Storage/IInstallationCasPoolService.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs create mode 100644 GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs diff --git a/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs b/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs index eac543d32..46a89d2c0 100644 --- a/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs +++ b/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs @@ -28,6 +28,12 @@ public interface ICasPoolResolver /// The root path for the appropriate pool. string GetPoolRootPath(ContentType contentType); + /// + /// Gets the previous installation-pool root retained for read-only lookup. + /// + /// The legacy root, or an empty string when none is configured. + string GetLegacyInstallationPoolRootPath(); + /// /// Checks if the installation pool is configured and available. /// diff --git a/GenHub/GenHub.Core/Interfaces/Storage/IInstallationCasPoolService.cs b/GenHub/GenHub.Core/Interfaces/Storage/IInstallationCasPoolService.cs new file mode 100644 index 000000000..1eef63cf5 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Storage/IInstallationCasPoolService.cs @@ -0,0 +1,19 @@ +using GenHub.Core.Models.GameInstallations; + +namespace GenHub.Core.Interfaces.Storage; + +/// +/// Selects and persists an effective installation CAS pool from detected installations. +/// +public interface IInstallationCasPoolService +{ + /// + /// Ensures installation-pool settings reflect the currently detected installations. + /// + /// The detected game installations. + /// A token that can cancel the settings update. + /// true when content acquisition may continue; otherwise, false. + Task EnsurePoolPathAsync( + IReadOnlyList installations, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs b/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs index 80c33dbba..e6d16f55b 100644 --- a/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs +++ b/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs @@ -45,6 +45,18 @@ public TimeSpan GcLockTimeout /// public string InstallationPoolRootPath { get; set; } = string.Empty; + /// + /// Gets or sets a value indicating whether was + /// selected automatically from a detected game installation. + /// + public bool IsInstallationPoolRootPathAutoDerived { get; set; } + + /// + /// Gets or sets a previous installation-pool root that remains available for read-only + /// object lookup after new writes have fallen back to another pool. + /// + public string LegacyInstallationPoolRootPath { get; set; } = string.Empty; + /// /// Gets or sets the hash algorithm to use for content addressing. /// @@ -133,6 +145,8 @@ public object Clone() EnableAutomaticGc = EnableAutomaticGc, CasRootPath = CasRootPath, InstallationPoolRootPath = InstallationPoolRootPath, + IsInstallationPoolRootPathAutoDerived = IsInstallationPoolRootPathAutoDerived, + LegacyInstallationPoolRootPath = LegacyInstallationPoolRootPath, HashAlgorithm = HashAlgorithm, GcGracePeriod = GcGracePeriod, MaxCacheSizeBytes = MaxCacheSizeBytes, diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs index 67685d879..dfbec8969 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs @@ -5,6 +5,7 @@ using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Storage; using Microsoft.Extensions.Logging; using Moq; @@ -21,6 +22,7 @@ public sealed class StorageLocationServiceTests : IDisposable private readonly Mock _configurationProviderService = new(); private readonly Mock _gameInstallationService = new(); private readonly string _applicationDataPath; + private readonly string _primaryCasPath; private readonly string _tempPath; /// @@ -30,9 +32,55 @@ public StorageLocationServiceTests() { _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); _applicationDataPath = Path.Combine(_tempPath, "AppData"); + _primaryCasPath = Path.Combine(_applicationDataPath, DirectoryNames.CasPool); Directory.CreateDirectory(_applicationDataPath); _configurationProviderService.Setup(service => service.GetApplicationDataPath()).Returns(_applicationDataPath); + _configurationProviderService + .Setup(service => service.GetCasConfiguration()) + .Returns(new CasConfiguration { CasRootPath = _primaryCasPath }); + } + + /// + /// Reports the effective primary CAS path when installation-adjacent storage is unavailable. + /// + [Fact] + public void GetCasPoolPath_WhenAdjacentPathIsUnavailable_UsesPrimaryPool() + { + var settings = new UserSettings { UseInstallationAdjacentStorage = true }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + var installationPath = Path.Combine(_tempPath, "Game"); + var installation = new GameInstallation(installationPath, GameInstallationType.Retail); + var adjacentPath = Path.Combine(installationPath, DirectoryNames.GenHubCasPool); + var probe = new Mock(); + probe.Setup(service => service.CanCreateStorageAt(adjacentPath)).Returns(false); + var service = CreateService(probe.Object); + + var result = service.GetCasPoolPath(installation); + + Assert.Equal(_primaryCasPath, result); + } + + /// + /// Reports a writable user-configured installation CAS path. + /// + [Fact] + public void GetCasPoolPath_WhenConfiguredPathIsWritable_UsesConfiguredPool() + { + var configuredPath = Path.Combine(_tempPath, "CustomCas"); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration { InstallationPoolRootPath = configuredPath }, + }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + var probe = new Mock(); + probe.Setup(service => service.CanCreateStorageAt(configuredPath)).Returns(true); + var service = CreateService(probe.Object); + var installation = new GameInstallation(Path.Combine(_tempPath, "Game"), GameInstallationType.Retail); + + var result = service.GetCasPoolPath(installation); + + Assert.Equal(configuredPath, result); } /// @@ -188,9 +236,10 @@ public void Dispose() GC.SuppressFinalize(this); } - private StorageLocationService CreateService() => new( + private StorageLocationService CreateService(IStorageWritabilityProbe? writabilityProbe = null) => new( _userSettingsService.Object, _configurationProviderService.Object, _gameInstallationService.Object, + writabilityProbe ?? new StorageWritabilityProbe(new Mock>().Object), new Mock>().Object); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index dc59a4c26..8681831a7 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -2,6 +2,7 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Content; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; @@ -22,8 +23,7 @@ public class ContentOrchestratorTests private readonly Mock _contentValidatorMock = default!; private readonly Mock _manifestPoolMock = default!; private readonly Mock _installationServiceMock = default!; - private readonly Mock _userSettingsServiceMock = default!; - private readonly Mock _writabilityProbeMock = default!; + private readonly Mock _installationCasPoolServiceMock = default!; private readonly Mock> _loggerMock = default!; /// @@ -35,8 +35,7 @@ public ContentOrchestratorTests() _contentValidatorMock = new Mock(); _manifestPoolMock = new Mock(); _installationServiceMock = new Mock(); - _userSettingsServiceMock = new Mock(); - _writabilityProbeMock = new Mock(); + _installationCasPoolServiceMock = new Mock(); _loggerMock = new Mock>(); } @@ -73,8 +72,7 @@ public async Task SearchAsync_AggregatesResultsFromMultipleProviders_Successfull _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _userSettingsServiceMock.Object, - _writabilityProbeMock.Object); + _installationCasPoolServiceMock.Object); // Act var result = await orchestrator.SearchAsync(new ContentSearchQuery()); @@ -135,8 +133,7 @@ public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully() _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _userSettingsServiceMock.Object, - _writabilityProbeMock.Object); + _installationCasPoolServiceMock.Object); // Act var result = await orchestrator.AcquireContentAsync(searchResult); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs index 968bbe454..503eed37d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs @@ -45,6 +45,21 @@ public void IsInstallationPoolAvailable_WhenPoolIsNotWritable_ReturnsFalse() Assert.False(resolver.IsInstallationPoolAvailable()); } + /// + /// Exposes an existing unwritable pool for read-only lookup before settings migration runs. + /// + [Fact] + public void GetLegacyInstallationPoolRootPath_WhenCurrentPoolIsUnwritable_ReturnsCurrentPath() + { + Directory.CreateDirectory(_installationPoolPath); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(_installationPoolPath)).Returns(false); + var resolver = CreateResolver(_installationPoolPath); + + var result = resolver.GetLegacyInstallationPoolRootPath(); + + Assert.Equal(_installationPoolPath, result); + } + /// /// Keeps a writable installation pool selected. /// @@ -60,9 +75,13 @@ public void IsInstallationPoolAvailable_WhenPoolIsWritable_ReturnsTrue() /// /// Routes installation-pool content to the primary pool when the installation pool is unwritable. /// + /// The content type normally routed to installation storage. [Theory] [InlineData(ContentType.GameClient)] [InlineData(ContentType.GameInstallation)] + [InlineData(ContentType.Addon)] + [InlineData(ContentType.Patch)] + [InlineData(ContentType.Map)] [InlineData(ContentType.Mod)] public void ResolvePool_WhenInstallationPoolIsNotWritable_UsesPrimaryPool(ContentType contentType) { @@ -135,9 +154,11 @@ public void StorageWritabilityProbe_WhenDirectoryDeniesWrites_ReturnsFalse() public void StorageWritabilityProbe_WhenLocationIsWritable_LeavesNoProbeFile() { var probe = new StorageWritabilityProbe(new Mock>().Object); + var targetPath = Path.Combine(_primaryPoolPath, ".genhub-cas"); - Assert.True(probe.CanCreateStorageAt(Path.Combine(_primaryPoolPath, ".genhub-cas"))); - Assert.Empty(Directory.GetFiles(_primaryPoolPath, ".genhub-write-probe-*")); + Assert.True(probe.CanCreateStorageAt(targetPath)); + Assert.True(Directory.Exists(targetPath)); + Assert.Empty(Directory.GetFiles(targetPath, ".genhub-write-probe-*")); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs new file mode 100644 index 000000000..07973af53 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -0,0 +1,261 @@ +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Storage; +using GenHub.Features.Storage.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using ManifestContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Storage; + +/// +/// Tests installation CAS pool selection, migration, and legacy lookup behavior. +/// +public sealed class InstallationCasPoolServiceTests : IDisposable +{ + private readonly string _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + private readonly Mock _userSettingsService = new(); + private readonly Mock _writabilityProbe = new(); + private readonly Mock _poolManager = new(); + + /// + /// Initializes a new instance of the class. + /// + public InstallationCasPoolServiceTests() + { + Directory.CreateDirectory(_tempPath); + } + + /// + /// Clears a historical auto-derived path and retains it for read-only lookup when it is unwritable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EnsurePoolPathAsync_WhenHistoricalPathIsUnwritable_PreservesLegacyLookup() + { + var installation = CreateInstallation(); + var poolPath = Path.Combine(installation.InstallationPath, ".genhub-cas"); + Directory.CreateDirectory(poolPath); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration { InstallationPoolRootPath = poolPath }, + ExplicitlySetProperties = [nameof(CasConfiguration.InstallationPoolRootPath)], + }; + ConfigureMutableSettings(settings); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(poolPath)).Returns(false); + var service = CreateService(); + + var result = await service.EnsurePoolPathAsync([installation]); + + Assert.True(result); + Assert.Empty(settings.CasConfiguration.InstallationPoolRootPath); + Assert.Equal(poolPath, settings.CasConfiguration.LegacyInstallationPoolRootPath); + Assert.False(settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived); + Assert.DoesNotContain(nameof(CasConfiguration.InstallationPoolRootPath), settings.ExplicitlySetProperties); + _poolManager.Verify(manager => manager.ReinitializeInstallationPool(), Times.Once); + } + + /// + /// Preserves a deliberate custom path instead of replacing it with an automatically derived path. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EnsurePoolPathAsync_WhenCustomPathIsConfigured_PreservesIt() + { + var installation = CreateInstallation(); + var customPath = Path.Combine(_tempPath, "custom-cas"); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration { InstallationPoolRootPath = customPath }, + }; + ConfigureMutableSettings(settings); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(customPath)).Returns(true); + var service = CreateService(); + + var result = await service.EnsurePoolPathAsync([installation]); + + Assert.True(result); + Assert.Equal(customPath, settings.CasConfiguration.InstallationPoolRootPath); + _userSettingsService.Verify( + service => service.TryUpdateAndSaveAsync(It.IsAny>()), + Times.Never); + _poolManager.Verify(manager => manager.ReinitializeInstallationPool(), Times.Never); + } + + /// + /// Persists provenance when a writable adjacent pool is selected automatically. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EnsurePoolPathAsync_WhenAdjacentPathIsWritable_RecordsAutoDerivedProvenance() + { + var installation = CreateInstallation(); + var poolPath = Path.Combine(installation.InstallationPath, ".genhub-cas"); + var settings = new UserSettings(); + ConfigureMutableSettings(settings); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(poolPath)).Returns(true); + var service = CreateService(); + + var result = await service.EnsurePoolPathAsync([installation]); + + Assert.True(result); + Assert.Equal(poolPath, settings.CasConfiguration.InstallationPoolRootPath); + Assert.True(settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived); + Assert.Equal(installation.Id, settings.PreferredStorageInstallationId); + _poolManager.Verify(manager => manager.ReinitializeInstallationPool(), Times.Once); + } + + /// + /// Removes a cached installation pool from every enumeration path after it becomes unavailable. + /// + [Fact] + public void CasPoolManager_WhenInstallationPoolBecomesUnavailable_DiscardsCachedStorage() + { + var primaryPath = Path.Combine(_tempPath, "primary"); + var installationPath = Path.Combine(_tempPath, "installation"); + var legacyPath = Path.Combine(_tempPath, "legacy"); + Directory.CreateDirectory(primaryPath); + Directory.CreateDirectory(installationPath); + Directory.CreateDirectory(legacyPath); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + InstallationPoolRootPath = installationPath, + LegacyInstallationPoolRootPath = legacyPath, + }, + }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(installationPath)).Returns(true); + var resolver = new CasPoolResolver( + Options.Create(new CasConfiguration { CasRootPath = primaryPath }), + _userSettingsService.Object, + _writabilityProbe.Object, + NullLogger.Instance); + var manager = new CasPoolManager( + resolver, + Options.Create(new CasConfiguration { CasRootPath = primaryPath }), + new Mock().Object, + NullLoggerFactory.Instance, + _writabilityProbe.Object, + NullLogger.Instance); + + Assert.Equal(3, manager.GetAllStorages().Count); + + settings.CasConfiguration.InstallationPoolRootPath = string.Empty; + manager.EnsureAllPoolsInitialized(); + + Assert.Equal(2, manager.GetAllStorages().Count); + Assert.Same(manager.GetStorage(CasPoolType.Primary), manager.GetStorage(CasPoolType.Installation)); + } + + /// + /// Reads an existing legacy object without attempting to create writable CAS directories. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CasStorage_ObjectExistsAsync_DoesNotCreateWriteDirectories() + { + var rootPath = Path.Combine(_tempPath, "read-only-cas"); + var hash = new string('a', 64); + var objectDirectory = Path.Combine(rootPath, "objects", "aa"); + Directory.CreateDirectory(objectDirectory); + await File.WriteAllTextAsync(Path.Combine(objectDirectory, hash), "content"); + var storage = new CasStorage( + Options.Create(new CasConfiguration { CasRootPath = rootPath }), + NullLogger.Instance, + new Mock().Object); + + Assert.True(await storage.ObjectExistsAsync(hash)); + Assert.False(Directory.Exists(Path.Combine(rootPath, "temp"))); + Assert.False(Directory.Exists(Path.Combine(rootPath, "locks"))); + } + + /// + /// Resolves content from the retained legacy pool after installation writes fall back to primary storage. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CasService_GetContentPathAsync_FindsContentInLegacyPool() + { + var primaryPath = Path.Combine(_tempPath, "primary-lookup"); + var legacyPath = Path.Combine(_tempPath, "legacy-lookup"); + var hash = new string('b', 64); + var objectDirectory = Path.Combine(legacyPath, "objects", "bb"); + Directory.CreateDirectory(primaryPath); + Directory.CreateDirectory(objectDirectory); + var expectedPath = Path.Combine(objectDirectory, hash); + await File.WriteAllTextAsync(expectedPath, "legacy content"); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + LegacyInstallationPoolRootPath = legacyPath, + }, + }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + var configuration = new CasConfiguration { CasRootPath = primaryPath }; + var resolver = new CasPoolResolver( + Options.Create(configuration), + _userSettingsService.Object, + _writabilityProbe.Object, + NullLogger.Instance); + var fileHashProvider = new Mock(); + var manager = new CasPoolManager( + resolver, + Options.Create(configuration), + fileHashProvider.Object, + NullLoggerFactory.Instance, + _writabilityProbe.Object, + NullLogger.Instance); + var service = new CasService( + manager.GetStorage(CasPoolType.Primary), + new Mock().Object, + NullLogger.Instance, + Options.Create(configuration), + fileHashProvider.Object, + new Mock().Object, + manager); + + var result = await service.GetContentPathAsync(hash, ManifestContentType.GameClient); + + Assert.True(result.Success); + Assert.Equal(expectedPath, result.Data); + } + + /// + public void Dispose() + { + Directory.Delete(_tempPath, true); + GC.SuppressFinalize(this); + } + + private GameInstallation CreateInstallation() + { + var installationPath = Path.Combine(_tempPath, "Game"); + Directory.CreateDirectory(installationPath); + return new GameInstallation(installationPath, GameInstallationType.Steam); + } + + private InstallationCasPoolService CreateService() + { + return new InstallationCasPoolService( + _userSettingsService.Object, + _writabilityProbe.Object, + _poolManager.Object, + NullLogger.Instance); + } + + private void ConfigureMutableSettings(UserSettings settings) + { + _userSettingsService.Setup(service => service.Get()).Returns(settings); + _userSettingsService + .Setup(service => service.TryUpdateAndSaveAsync(It.IsAny>())) + .Returns>(applyChanges => Task.FromResult(applyChanges(settings))); + } +} diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs index 9956eb328..337711ed5 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -321,6 +321,8 @@ public CasConfiguration GetCasConfiguration() { CasRootPath = defaultPath, InstallationPoolRootPath = casConfig.InstallationPoolRootPath, + IsInstallationPoolRootPathAutoDerived = casConfig.IsInstallationPoolRootPathAutoDerived, + LegacyInstallationPoolRootPath = casConfig.LegacyInstallationPoolRootPath, EnableAutomaticGc = casConfig.EnableAutomaticGc, HashAlgorithm = casConfig.HashAlgorithm, GcGracePeriod = casConfig.GcGracePeriod, diff --git a/GenHub/GenHub/Common/Services/StorageLocationService.cs b/GenHub/GenHub/Common/Services/StorageLocationService.cs index 44b03f963..959d8fbf4 100644 --- a/GenHub/GenHub/Common/Services/StorageLocationService.cs +++ b/GenHub/GenHub/Common/Services/StorageLocationService.cs @@ -1,9 +1,7 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Security; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; @@ -22,35 +20,43 @@ public class StorageLocationService( IUserSettingsService userSettingsService, IConfigurationProviderService configurationProviderService, IGameInstallationService gameInstallationService, + IStorageWritabilityProbe writabilityProbe, ILogger logger) : IStorageLocationService { - /// - /// Caches write-probe results for the process lifetime, so a permission change - /// on a probed location only takes effect after a restart. - /// - private readonly ConcurrentDictionary _writableStorageCache = new(PathHelper.PathComparer); - /// public string GetCasPoolPath(IGameInstallation installation) { ArgumentNullException.ThrowIfNull(installation); var settings = userSettingsService.Get(); - if (!settings.UseInstallationAdjacentStorage) + var configuredInstallationPoolPath = settings.CasConfiguration.InstallationPoolRootPath; + if (!string.IsNullOrWhiteSpace(configuredInstallationPoolPath) && + writabilityProbe.CanCreateStorageAt(configuredInstallationPoolPath)) { - // Fall back to centralized AppData location - var appDataPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - AppConstants.AppName, - DirectoryNames.CasPool); - logger.LogDebug("Using centralized CAS pool path: {CasPoolPath} (installation-adjacent disabled)", appDataPath); - return appDataPath; + return Path.GetFullPath(configuredInstallationPoolPath); } - var installationRoot = PathHelper.GetSafeParentDirectory(installation.InstallationPath); - var casPoolPath = Path.Combine(installationRoot, DirectoryNames.GenHubCasPool); - logger.LogDebug("Resolved CAS pool path: {CasPoolPath} for installation {InstallationId}", casPoolPath, installation.Id); - return casPoolPath; + if (settings.UseInstallationAdjacentStorage) + { + var installationPath = Path.HasExtension(installation.InstallationPath) + ? Path.GetDirectoryName(installation.InstallationPath) + : installation.InstallationPath; + if (!string.IsNullOrWhiteSpace(installationPath)) + { + var adjacentPath = Path.Combine(installationPath, DirectoryNames.GenHubCasPool); + if (writabilityProbe.CanCreateStorageAt(adjacentPath)) + { + return Path.GetFullPath(adjacentPath); + } + } + } + + var primaryPoolPath = configurationProviderService.GetCasConfiguration().CasRootPath; + logger.LogInformation( + "Using primary CAS pool path {CasPoolPath} for installation {InstallationId}", + primaryPoolPath, + installation.Id); + return primaryPoolPath; } /// @@ -70,7 +76,7 @@ public string GetWorkspacePath(IGameInstallation installation) } var configuredWorkspacePath = settings.WorkspacePath; - var workspacePath = !string.IsNullOrWhiteSpace(configuredWorkspacePath) && CanCreateStorageAt(configuredWorkspacePath) + var workspacePath = !string.IsNullOrWhiteSpace(configuredWorkspacePath) && writabilityProbe.CanCreateStorageAt(configuredWorkspacePath) ? Path.GetFullPath(configuredWorkspacePath) : Path.Combine(configurationProviderService.GetApplicationDataPath(), DirectoryNames.Workspaces); @@ -176,7 +182,7 @@ private bool TryGetWritableInstallationAdjacentPath( { var installationRoot = PathHelper.GetSafeParentDirectory(installation.InstallationPath); path = Path.Combine(installationRoot, directoryName); - if (CanCreateStorageAt(path)) + if (writabilityProbe.CanCreateStorageAt(path)) { return true; } @@ -187,84 +193,4 @@ private bool TryGetWritableInstallationAdjacentPath( installation.Id); return false; } - - private bool CanCreateStorageAt(string storagePath) - { - string fullStoragePath; - - try - { - fullStoragePath = Path.GetFullPath(storagePath); - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or SecurityException or IOException) - { - logger.LogDebug(ex, "Storage path {StoragePath} could not be resolved", storagePath); - return false; - } - - return _writableStorageCache.GetOrAdd(fullStoragePath, ProbeStorageLocation); - } - - private bool ProbeStorageLocation(string fullStoragePath) - { - string? probePath = null; - var storageDirectoryExisted = Directory.Exists(fullStoragePath); - var storageDirectoryCreated = false; - var probeSucceeded = false; - - try - { - Directory.CreateDirectory(fullStoragePath); - storageDirectoryCreated = !storageDirectoryExisted; - - probePath = Path.Combine( - fullStoragePath, - $"{StorageConstants.WriteProbeFilePrefix}{Guid.NewGuid():N}.tmp"); - using var probe = new FileStream( - probePath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 1, - FileOptions.DeleteOnClose); - probe.WriteByte(0); - probeSucceeded = true; - return true; - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or ArgumentException or NotSupportedException or SecurityException) - { - logger.LogDebug(ex, "Storage path {StoragePath} is not writable", fullStoragePath); - return false; - } - finally - { - if (!string.IsNullOrWhiteSpace(probePath) && File.Exists(probePath)) - { - try - { - File.Delete(probePath); - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) - { - logger.LogDebug(ex, "Could not remove storage write probe {ProbePath}", probePath); - } - } - - if (!probeSucceeded && storageDirectoryCreated) - { - try - { - if (Directory.Exists(fullStoragePath) && - !Directory.EnumerateFileSystemEntries(fullStoragePath).Any()) - { - Directory.Delete(fullStoragePath); - } - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or SecurityException) - { - logger.LogDebug(ex, "Could not remove failed storage probe directory {StoragePath}", fullStoragePath); - } - } - } - } } diff --git a/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs b/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs index 5dfc20239..4fddd2c98 100644 --- a/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs +++ b/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Concurrent; using System.IO; +using System.Linq; using System.Security; +using GenHub.Core.Constants; using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using Microsoft.Extensions.Logging; @@ -13,8 +15,6 @@ namespace GenHub.Common.Services; /// public class StorageWritabilityProbe(ILogger logger) : IStorageWritabilityProbe { - private const string ProbeFilePrefix = ".genhub-write-probe-"; - private readonly ConcurrentDictionary _results = new(PathHelper.PathComparer); /// @@ -62,20 +62,18 @@ public void Invalidate(string? storagePath = null) private bool Probe(string fullStoragePath) { string? probePath = null; + var storageDirectoryExisted = Directory.Exists(fullStoragePath); + var storageDirectoryCreated = false; + var probeSucceeded = false; try { - var probeDirectory = Directory.Exists(fullStoragePath) - ? fullStoragePath - : Path.GetDirectoryName(fullStoragePath); - - if (string.IsNullOrWhiteSpace(probeDirectory) || !Directory.Exists(probeDirectory)) - { - logger.LogDebug("Storage path {StoragePath} has no existing directory to probe", fullStoragePath); - return false; - } + Directory.CreateDirectory(fullStoragePath); + storageDirectoryCreated = !storageDirectoryExisted; - probePath = Path.Combine(probeDirectory, $"{ProbeFilePrefix}{Guid.NewGuid():N}.tmp"); + probePath = Path.Combine( + fullStoragePath, + $"{StorageConstants.WriteProbeFilePrefix}{Guid.NewGuid():N}.tmp"); using var probe = new FileStream( probePath, FileMode.CreateNew, @@ -84,6 +82,7 @@ private bool Probe(string fullStoragePath) bufferSize: 1, FileOptions.DeleteOnClose); probe.WriteByte(0); + probeSucceeded = true; return true; } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or ArgumentException or NotSupportedException or SecurityException) @@ -104,6 +103,22 @@ private bool Probe(string fullStoragePath) logger.LogDebug(ex, "Could not remove storage write probe {ProbePath}", probePath); } } + + if (!probeSucceeded && storageDirectoryCreated) + { + try + { + if (Directory.Exists(fullStoragePath) && + !Directory.EnumerateFileSystemEntries(fullStoragePath).Any()) + { + Directory.Delete(fullStoragePath); + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or SecurityException) + { + logger.LogDebug(ex, "Could not remove failed storage probe directory {StoragePath}", fullStoragePath); + } + } } } } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index fcd18d78d..53dd11bb3 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -36,10 +36,8 @@ public class CommunityOutpostDeliverer( IContentManifestPool manifestPool, CommunityOutpostManifestFactory manifestFactory, IGameInstallationService installationService, - IUserSettingsService userSettingsService, - ICasPoolManager? casPoolManager, + IInstallationCasPoolService installationCasPoolService, CompressedImageToTgaConverter avifConverter, - IStorageWritabilityProbe writabilityProbe, ILogger logger) : IContentDeliverer { @@ -81,37 +79,6 @@ private static string GetContentCodeFromManifest(ContentManifest manifest) return "unknown"; } - /// - /// Gets the installation path for a game installation. - /// - private static string? GetInstallationPath(GameInstallation installation) - { - // For Zero Hour installations, use the installation path directly - // For Generals-only installations, use the Generals path - if (!string.IsNullOrEmpty(installation.InstallationPath)) - { - // If the path points to a file (e.g. generals.exe), return the directory - if (Path.HasExtension(installation.InstallationPath)) - { - return Path.GetDirectoryName(installation.InstallationPath); - } - - return installation.InstallationPath; - } - - if (!string.IsNullOrEmpty(installation.ZeroHourPath)) - { - return installation.ZeroHourPath; - } - - if (!string.IsNullOrEmpty(installation.GeneralsPath)) - { - return installation.GeneralsPath; - } - - return null; - } - /// /// Extracts an archive (ZIP, 7z, etc.) asynchronously using SharpCompress. /// Automatically detects format. @@ -365,11 +332,6 @@ await ProcessAndMergeDependencyBigFilesAsync( if (hasGameClientManifest) { await EnsureInstallationPoolPathAsync(cancellationToken); - - // CRITICAL: Force the CAS pool manager to reinitialize the Installation pool - // after we've updated the path in settings. Without this, the pool manager - // will still use the old (or non-existent) Installation pool. - casPoolManager?.ReinitializeInstallationPool(); } foreach (var manifest in manifests) @@ -676,59 +638,7 @@ private async Task EnsureInstallationPoolPathAsync(CancellationToken cancellatio return; } - // If multiple installations, prefer Steam over EA App - var preferredInstallation = installations.Count == 1 - ? installations[0] - : installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.Steam) - ?? installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.EaApp) - ?? installations.FirstOrDefault(); - - if (preferredInstallation == null) - { - logger.LogWarning("No valid installation found for CAS pool path resolution"); - return; - } - - var preferredInstallationPath = GetInstallationPath(preferredInstallation); - if (string.IsNullOrEmpty(preferredInstallationPath)) - { - logger.LogWarning("Preferred installation {InstallationId} has no usable path", preferredInstallation.Id); - return; - } - - var poolPath = Path.Combine(preferredInstallationPath, DirectoryNames.GenHubCasPool); - - // An unwritable pool is not a failure: clearing the path routes this content to the - // primary pool instead, and also repairs a protected path persisted by an earlier run. - var isPoolWritable = writabilityProbe.CanCreateStorageAt(poolPath); - if (isPoolWritable) - { - logger.LogInformation( - "Auto-setting InstallationPoolRootPath to preferred installation ({InstallationType}): {Path}", - preferredInstallation.InstallationType, - poolPath); - } - else - { - logger.LogWarning( - "Installation CAS pool {Path} is not writable; content will be stored in the primary pool", - poolPath); - } - - var saved = await userSettingsService.TryUpdateAndSaveAsync(s => - { - s.CasConfiguration.InstallationPoolRootPath = isPoolWritable ? poolPath : string.Empty; - s.PreferredStorageInstallationId = preferredInstallation.Id; - return true; - }); - - if (!saved) - { - logger.LogError("Failed to save installation pool path settings for installation {InstallationId}", preferredInstallation.Id); - } - - var updatedSettings = userSettingsService.Get(); - logger.LogInformation("Verified InstallationPoolRootPath is now: {Path}", updatedSettings.CasConfiguration.InstallationPoolRootPath); + await installationCasPoolService.EnsurePoolPathAsync(installations, cancellationToken); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs index c79b52100..0b7d74322 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs @@ -10,6 +10,7 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameInstallations; @@ -36,40 +37,9 @@ public class ContentOrchestrator : IContentOrchestrator private readonly IContentValidator _contentValidator; private readonly IContentManifestPool _manifestPool; private readonly IGameInstallationService _installationService; - private readonly IUserSettingsService _userSettingsService; - private readonly IStorageWritabilityProbe _writabilityProbe; + private readonly IInstallationCasPoolService _installationCasPoolService; private readonly object _providerLock = new(); - /// - /// Gets the installation path for a game installation. - /// - private static string? GetInstallationPath(GenHub.Core.Models.GameInstallations.GameInstallation? installation) - { - if (installation == null) - { - return null; - } - - // For Zero Hour installations, use the installation path directly - // For Generals-only installations, use the Generals path - if (!string.IsNullOrEmpty(installation.InstallationPath)) - { - return installation.InstallationPath; - } - - if (!string.IsNullOrEmpty(installation.ZeroHourPath)) - { - return installation.ZeroHourPath; - } - - if (!string.IsNullOrEmpty(installation.GeneralsPath)) - { - return installation.GeneralsPath; - } - - return null; - } - /// /// Initializes a new instance of the class. /// @@ -81,8 +51,7 @@ public class ContentOrchestrator : IContentOrchestrator /// The content validator service for manifest and content integrity. /// The manifest pool for acquired content. /// The game installation service for detecting installations. - /// The user settings service for updating CAS configuration. - /// The probe used to confirm a CAS pool location accepts writes. + /// The installation CAS pool selector. public ContentOrchestrator( ILogger logger, IEnumerable providers, @@ -92,8 +61,7 @@ public ContentOrchestrator( IContentValidator contentValidator, IContentManifestPool manifestPool, IGameInstallationService installationService, - IUserSettingsService userSettingsService, - IStorageWritabilityProbe writabilityProbe) + IInstallationCasPoolService installationCasPoolService) { _logger = logger; _providers = [.. providers]; @@ -111,8 +79,7 @@ public ContentOrchestrator( _contentValidator = contentValidator; _manifestPool = manifestPool; _installationService = installationService; - _userSettingsService = userSettingsService; - _writabilityProbe = writabilityProbe; + _installationCasPoolService = installationCasPoolService; _logger.LogInformation("ContentOrchestrator initialized with {ProviderCount} providers, {DiscovererCount} discoverers, {ResolverCount} resolvers", _providers.Count, _discoverers.Count, _resolvers.Count); } @@ -740,46 +707,7 @@ private async Task EnsureInstallationPoolPathAsync(CancellationToken cance return false; } - // If multiple installations, prefer Steam over EA App - // Note: Since we verified installations.Count >= 1, this will never be null - var preferredInstallation = installations.Count == 1 - ? installations[0] - : installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.Steam) - ?? installations.FirstOrDefault(i => i.InstallationType == GameInstallationType.EaApp) - ?? installations.First(); - - var preferredInstallationPath = GetInstallationPath(preferredInstallation); - if (string.IsNullOrEmpty(preferredInstallationPath)) - { - _logger.LogWarning("Preferred installation {InstallationId} has no usable path", preferredInstallation.Id); - return false; - } - - var casPoolPath = Path.Combine(preferredInstallationPath, DirectoryNames.GenHubCasPool); - - // An unwritable pool is not a failure: clearing the path routes this content to the - // primary pool instead, and also repairs a protected path persisted by an earlier run. - var isPoolWritable = _writabilityProbe.CanCreateStorageAt(casPoolPath); - if (isPoolWritable) - { - _logger.LogInformation( - "Auto-setting InstallationPoolRootPath to preferred installation ({InstallationType}): {Path}", - preferredInstallation.InstallationType, - casPoolPath); - } - else - { - _logger.LogWarning( - "Installation CAS pool {Path} is not writable; content will be stored in the primary pool", - casPoolPath); - } - - return await _userSettingsService.TryUpdateAndSaveAsync(s => - { - s.CasConfiguration.InstallationPoolRootPath = isPoolWritable ? casPoolPath : string.Empty; - s.PreferredStorageInstallationId = preferredInstallation.Id; - return true; - }); + return await _installationCasPoolService.EnsurePoolPathAsync(installations, cancellationToken); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index 64e5e5925..ac9ff93bd 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -22,10 +22,13 @@ public class CasPoolManager : ICasPoolManager private readonly ILogger _logger; private readonly IFileHashProvider _hashProvider; private readonly ILoggerFactory _loggerFactory; + private readonly IStorageWritabilityProbe _writabilityProbe; private readonly CasConfiguration _config; private readonly ConcurrentDictionary _storages = new(); private readonly object _initLock = new(); private string? _installationPoolRoot; + private ICasStorage? _legacyInstallationStorage; + private string? _legacyInstallationPoolRoot; /// /// Initializes a new instance of the class. @@ -34,28 +37,27 @@ public class CasPoolManager : ICasPoolManager /// The CAS configuration. /// The file hash provider. /// The logger factory for creating storage loggers. + /// The storage writability probe. /// The logger instance. public CasPoolManager( ICasPoolResolver poolResolver, IOptions config, IFileHashProvider hashProvider, ILoggerFactory loggerFactory, + IStorageWritabilityProbe writabilityProbe, ILogger logger) { _poolResolver = poolResolver; _config = config.Value; _hashProvider = hashProvider; _loggerFactory = loggerFactory; + _writabilityProbe = writabilityProbe; _logger = logger; // Initialize primary pool InitializePool(CasPoolType.Primary); - // Initialize installation pool if configured - if (_poolResolver.IsInstallationPoolAvailable()) - { - InitializePool(CasPoolType.Installation); - } + RefreshInstallationPools(); } /// @@ -64,10 +66,7 @@ public CasPoolManager( /// public ICasStorage GetStorage(CasPoolType poolType) { - if (poolType == CasPoolType.Installation) - { - DiscardInstallationPoolIfRootChanged(); - } + RefreshInstallationPools(); if (_storages.TryGetValue(poolType, out var storage)) { @@ -118,7 +117,14 @@ public ICasStorage GetStorage(ContentType contentType) /// public IReadOnlyList GetAllStorages() { - return _storages.Values.ToList().AsReadOnly(); + RefreshInstallationPools(); + var storages = _storages.Values.ToList(); + if (_legacyInstallationStorage != null && !storages.Contains(_legacyInstallationStorage)) + { + storages.Add(_legacyInstallationStorage); + } + + return storages.AsReadOnly(); } /// @@ -136,12 +142,7 @@ public void EnsureAllPoolsInitialized() InitializePool(CasPoolType.Primary); } - // Try to initialize Installation pool if available - if (!_storages.ContainsKey(CasPoolType.Installation) && _poolResolver.IsInstallationPoolAvailable()) - { - _logger.LogInformation("Installation pool not initialized but is available, initializing now"); - InitializePool(CasPoolType.Installation); - } + RefreshInstallationPools(); } /// @@ -152,21 +153,21 @@ public void ReinitializeInstallationPool() { _logger.LogInformation("Force reinitializing Installation CAS pool"); - // Remove existing Installation pool if present - if (_storages.TryRemove(CasPoolType.Installation, out _)) - { - _logger.LogDebug("Removed existing Installation pool for reinitialization"); - } + _writabilityProbe.Invalidate(); - // Reinitialize if path is available - if (_poolResolver.IsInstallationPoolAvailable()) - { - InitializePool(CasPoolType.Installation); - } - else + lock (_initLock) { - _logger.LogWarning("Installation pool path not available, cannot reinitialize"); + if (_storages.TryRemove(CasPoolType.Installation, out _)) + { + _logger.LogDebug("Removed existing Installation pool for reinitialization"); + } + + _installationPoolRoot = null; + _legacyInstallationStorage = null; + _legacyInstallationPoolRoot = null; } + + RefreshInstallationPools(); } private void InitializePool(CasPoolType poolType) @@ -179,81 +180,109 @@ private void InitializePool(CasPoolType poolType) lock (_initLock) { - if (_storages.ContainsKey(poolType)) - { - _logger.LogDebug("Pool {PoolType} already initialized (race condition prevented)", poolType); - return; - } + if (_storages.ContainsKey(poolType)) + { + _logger.LogDebug("Pool {PoolType} already initialized (race condition prevented)", poolType); + return; + } - var rootPath = _poolResolver.GetPoolRootPath(poolType); - if (string.IsNullOrWhiteSpace(rootPath)) - { - _logger.LogWarning("Cannot initialize {PoolType} pool: root path is not configured", poolType); - return; - } + var rootPath = _poolResolver.GetPoolRootPath(poolType); + if (string.IsNullOrWhiteSpace(rootPath)) + { + _logger.LogWarning("Cannot initialize {PoolType} pool: root path is not configured", poolType); + return; + } - // Security Guard: Prevent initializing CAS in the application directory or an empty path - var appBaseDir = Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory); - var normalizedRootPath = Path.TrimEndingDirectorySeparator(rootPath); + // Security Guard: Prevent initializing CAS in the application directory or an empty path + var appBaseDir = Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory); + var normalizedRootPath = Path.TrimEndingDirectorySeparator(rootPath); - if (normalizedRootPath.Equals(appBaseDir, StringComparison.OrdinalIgnoreCase) || - normalizedRootPath.StartsWith(appBaseDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError("Security Block: Attempted to initialize {PoolType} CAS pool at or inside the application directory: {Path}. This is not allowed.", poolType, rootPath); - return; - } + if (normalizedRootPath.Equals(appBaseDir, StringComparison.OrdinalIgnoreCase) || + normalizedRootPath.StartsWith(appBaseDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError("Security Block: Attempted to initialize {PoolType} CAS pool at or inside the application directory: {Path}. This is not allowed.", poolType, rootPath); + return; + } - // Create a configuration specific to this pool - var poolConfig = new CasConfiguration - { - CasRootPath = rootPath, - HashAlgorithm = _config.HashAlgorithm, - GcGracePeriod = _config.GcGracePeriod, - MaxCacheSizeBytes = _config.MaxCacheSizeBytes, - AutoGcInterval = _config.AutoGcInterval, - MaxConcurrentOperations = _config.MaxConcurrentOperations, - VerifyIntegrity = _config.VerifyIntegrity, - EnableAutomaticGc = _config.EnableAutomaticGc, - }; - - var poolConfigOptions = Options.Create(poolConfig); - var storageLogger = _loggerFactory.CreateLogger(); - - var storage = new CasStorage(poolConfigOptions, storageLogger, _hashProvider); - _storages.TryAdd(poolType, storage); - - if (poolType == CasPoolType.Installation) - { - _installationPoolRoot = rootPath; - } + var storage = CreateStorage(rootPath); + _storages.TryAdd(poolType, storage); - _logger.LogInformation("Initialized {PoolType} CAS pool at {RootPath}", poolType, rootPath); + if (poolType == CasPoolType.Installation) + { + _installationPoolRoot = rootPath; } + + _logger.LogInformation("Initialized {PoolType} CAS pool at {RootPath}", poolType, rootPath); + } } - private void DiscardInstallationPoolIfRootChanged() + private ICasStorage CreateStorage(string rootPath) { - if (!_storages.ContainsKey(CasPoolType.Installation)) + var poolConfig = new CasConfiguration { - return; + CasRootPath = rootPath, + HashAlgorithm = _config.HashAlgorithm, + GcGracePeriod = _config.GcGracePeriod, + MaxCacheSizeBytes = _config.MaxCacheSizeBytes, + AutoGcInterval = _config.AutoGcInterval, + MaxConcurrentOperations = _config.MaxConcurrentOperations, + VerifyIntegrity = _config.VerifyIntegrity, + EnableAutomaticGc = _config.EnableAutomaticGc, + }; + + return new CasStorage( + Options.Create(poolConfig), + _loggerFactory.CreateLogger(), + _hashProvider); + } + + private void RefreshInstallationPools() + { + lock (_initLock) + { + var installationPoolAvailable = _poolResolver.IsInstallationPoolAvailable(); + var currentRoot = installationPoolAvailable + ? _poolResolver.GetPoolRootPath(CasPoolType.Installation) + : string.Empty; + + if (_storages.ContainsKey(CasPoolType.Installation) && + (!installationPoolAvailable || + !string.Equals(currentRoot, _installationPoolRoot, PathHelper.PathComparison))) + { + _storages.TryRemove(CasPoolType.Installation, out _); + _logger.LogInformation("Discarded cached installation CAS pool at {PreviousRoot}", _installationPoolRoot); + _installationPoolRoot = null; + } + + if (installationPoolAvailable && !_storages.ContainsKey(CasPoolType.Installation)) + { + InitializePool(CasPoolType.Installation); + } + + RefreshLegacyInstallationPool(currentRoot); } + } - var currentRoot = _poolResolver.GetPoolRootPath(CasPoolType.Installation); - if (string.Equals(currentRoot, _installationPoolRoot, PathHelper.PathComparison)) + private void RefreshLegacyInstallationPool(string activeInstallationRoot) + { + var legacyRoot = _poolResolver.GetLegacyInstallationPoolRootPath(); + if (string.IsNullOrWhiteSpace(legacyRoot) || + !Directory.Exists(legacyRoot) || + string.Equals(legacyRoot, activeInstallationRoot, PathHelper.PathComparison)) { + _legacyInstallationStorage = null; + _legacyInstallationPoolRoot = null; return; } - lock (_initLock) + legacyRoot = Path.GetFullPath(legacyRoot); + if (string.Equals(legacyRoot, _legacyInstallationPoolRoot, PathHelper.PathComparison)) { - if (_storages.TryRemove(CasPoolType.Installation, out _)) - { - _logger.LogInformation( - "Installation CAS pool root changed from {PreviousRoot} to {CurrentRoot}; discarding the cached pool", - _installationPoolRoot, - currentRoot); - _installationPoolRoot = null; - } + return; } + + _legacyInstallationStorage = CreateStorage(legacyRoot); + _legacyInstallationPoolRoot = legacyRoot; + _logger.LogInformation("Retaining legacy installation CAS pool {LegacyRoot} for read-only lookup", legacyRoot); } } diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs index f75dd5555..171f80652 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; @@ -71,6 +72,23 @@ public string GetPoolRootPath(ContentType contentType) return GetPoolRootPath(poolType); } + /// + public string GetLegacyInstallationPoolRootPath() + { + var configuration = userSettingsService.Get().CasConfiguration; + if (!string.IsNullOrWhiteSpace(configuration.LegacyInstallationPoolRootPath)) + { + return configuration.LegacyInstallationPoolRootPath; + } + + var currentPath = configuration.InstallationPoolRootPath; + return !string.IsNullOrWhiteSpace(currentPath) && + Directory.Exists(currentPath) && + !writabilityProbe.CanCreateStorageAt(currentPath) + ? currentPath + : string.Empty; + } + /// public bool IsInstallationPoolAvailable() { diff --git a/GenHub/GenHub/Features/Storage/Services/CasService.cs b/GenHub/GenHub/Features/Storage/Services/CasService.cs index 09df977eb..b1a7fadb9 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasService.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasService.cs @@ -564,6 +564,21 @@ public async Task> GetContentPathAsync( return OperationResult.CreateSuccess(path); } + foreach (var fallbackStorage in poolManager.GetAllStorages()) + { + if (ReferenceEquals(fallbackStorage, storage) || ReferenceEquals(fallbackStorage, primaryStorage)) + { + continue; + } + + if (await fallbackStorage.ObjectExistsAsync(hash, cancellationToken)) + { + var path = fallbackStorage.GetObjectPath(hash); + logger.LogInformation("Found content {Hash} in a legacy CAS pool", hash); + return OperationResult.CreateSuccess(path); + } + } + return OperationResult.CreateFailure($"Content not found in CAS: {hash}"); } catch (Exception ex) @@ -608,6 +623,24 @@ public async Task> ExistsAsync( } } + if (!exists) + { + foreach (var fallbackStorage in poolManager.GetAllStorages()) + { + if (ReferenceEquals(fallbackStorage, storage)) + { + continue; + } + + if (await fallbackStorage.ObjectExistsAsync(hash, cancellationToken)) + { + logger.LogInformation("Found content {Hash} in a legacy CAS pool", hash); + exists = true; + break; + } + } + } + return OperationResult.CreateSuccess(exists); } catch (Exception ex) diff --git a/GenHub/GenHub/Features/Storage/Services/CasStorage.cs b/GenHub/GenHub/Features/Storage/Services/CasStorage.cs index d5f4ca4a5..38d3ff76b 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasStorage.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasStorage.cs @@ -33,7 +33,6 @@ public class CasStorage( /// public string GetObjectPath(string hash) { - EnsureDirectoriesCreated(); ValidateHashFormat(hash); var subDirectory = hash[..2].ToLowerInvariant(); return Path.Combine(_objectsDirectory, subDirectory, hash.ToLowerInvariant()); diff --git a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs new file mode 100644 index 000000000..b98a821d4 --- /dev/null +++ b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Storage; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Storage.Services; + +/// +/// Selects a writable installation CAS pool while preserving prior readable content. +/// +public sealed class InstallationCasPoolService( + IUserSettingsService userSettingsService, + IStorageWritabilityProbe writabilityProbe, + ICasPoolManager casPoolManager, + ILogger logger) : IInstallationCasPoolService +{ + private const string ExplicitInstallationPoolPathKey = nameof(CasConfiguration.InstallationPoolRootPath); + + /// + public async Task EnsurePoolPathAsync( + IReadOnlyList installations, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(installations); + + if (installations.Count == 0) + { + logger.LogWarning("No installations detected; the primary CAS pool will be used"); + return false; + } + + var preferredInstallation = installations.Count == 1 + ? installations[0] + : installations.FirstOrDefault(installation => installation.InstallationType == GameInstallationType.Steam) + ?? installations.FirstOrDefault(installation => installation.InstallationType == GameInstallationType.EaApp) + ?? installations[0]; + + var derivedPaths = installations + .Select(GetDerivedPoolPath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .ToHashSet(PathHelper.PathComparer); + var candidatePath = GetDerivedPoolPath(preferredInstallation); + if (string.IsNullOrWhiteSpace(candidatePath)) + { + logger.LogWarning( + "Preferred installation {InstallationId} has no usable path; the primary CAS pool will be used", + preferredInstallation.Id); + return false; + } + + candidatePath = NormalizePath(candidatePath); + if (string.IsNullOrWhiteSpace(candidatePath)) + { + logger.LogWarning("The derived installation CAS pool path is invalid; the primary pool will be used"); + return false; + } + + var currentSettings = userSettingsService.Get(); + var configuredCurrentPath = currentSettings.CasConfiguration.InstallationPoolRootPath; + var currentPath = NormalizePath(configuredCurrentPath); + var historicalAutoDerivedMarker = + currentSettings.ExplicitlySetProperties.Contains(ExplicitInstallationPoolPathKey); + if (!string.IsNullOrWhiteSpace(configuredCurrentPath) && + string.IsNullOrWhiteSpace(currentPath) && + !currentSettings.CasConfiguration.IsInstallationPoolRootPathAutoDerived && + !historicalAutoDerivedMarker) + { + logger.LogWarning( + "User-configured installation CAS pool {PoolPath} is invalid; preserving the setting and using the primary pool", + configuredCurrentPath); + return true; + } + + var currentIsAutoDerived = IsAutoDerived(currentSettings, currentPath, derivedPaths); + + if (!string.IsNullOrWhiteSpace(currentPath) && !currentIsAutoDerived) + { + if (writabilityProbe.CanCreateStorageAt(currentPath)) + { + logger.LogInformation("Keeping user-configured installation CAS pool {PoolPath}", currentPath); + } + else + { + logger.LogWarning( + "User-configured installation CAS pool {PoolPath} is not writable; preserving the setting and using the primary pool", + currentPath); + } + + return true; + } + + var candidateIsWritable = writabilityProbe.CanCreateStorageAt(candidatePath); + var effectivePath = candidateIsWritable ? candidatePath : string.Empty; + var legacyPath = SelectLegacyPath(currentSettings, currentPath, candidatePath, effectivePath); + var settingsAlreadyMatch = + string.Equals(currentPath, effectivePath, PathHelper.PathComparison) && + currentSettings.CasConfiguration.IsInstallationPoolRootPathAutoDerived == candidateIsWritable && + string.Equals( + NormalizePath(currentSettings.CasConfiguration.LegacyInstallationPoolRootPath), + legacyPath, + PathHelper.PathComparison) && + string.Equals(currentSettings.PreferredStorageInstallationId, preferredInstallation.Id, StringComparison.Ordinal) && + !currentSettings.ExplicitlySetProperties.Contains(ExplicitInstallationPoolPathKey); + if (settingsAlreadyMatch) + { + return true; + } + + var saved = await userSettingsService.TryUpdateAndSaveAsync(settings => + { + settings.CasConfiguration.InstallationPoolRootPath = effectivePath; + settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived = candidateIsWritable; + settings.CasConfiguration.LegacyInstallationPoolRootPath = legacyPath; + settings.PreferredStorageInstallationId = preferredInstallation.Id; + settings.ExplicitlySetProperties.Remove(ExplicitInstallationPoolPathKey); + return true; + }); + + if (!saved) + { + logger.LogError("Failed to save installation CAS pool settings"); + return false; + } + + if (candidateIsWritable) + { + logger.LogInformation( + "Using installation-adjacent CAS pool {PoolPath} for installation {InstallationId}", + candidatePath, + preferredInstallation.Id); + } + else + { + logger.LogWarning( + "Installation-adjacent CAS pool {PoolPath} is not writable; new content will use the primary pool", + candidatePath); + } + + casPoolManager.ReinitializeInstallationPool(); + return true; + } + + private static string? GetDerivedPoolPath(GameInstallation installation) + { + var installationPath = !string.IsNullOrWhiteSpace(installation.InstallationPath) + ? installation.InstallationPath + : !string.IsNullOrWhiteSpace(installation.ZeroHourPath) + ? installation.ZeroHourPath + : installation.GeneralsPath; + + if (string.IsNullOrWhiteSpace(installationPath)) + { + return null; + } + + if (Path.HasExtension(installationPath)) + { + installationPath = Path.GetDirectoryName(installationPath); + } + + return string.IsNullOrWhiteSpace(installationPath) + ? null + : Path.Combine(installationPath, DirectoryNames.GenHubCasPool); + } + + private static bool IsAutoDerived( + UserSettings settings, + string currentPath, + IReadOnlySet derivedPaths) + { + if (string.IsNullOrWhiteSpace(currentPath)) + { + return true; + } + + return settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived || + settings.ExplicitlySetProperties.Contains(ExplicitInstallationPoolPathKey) || + derivedPaths.Contains(currentPath); + } + + private static string SelectLegacyPath( + UserSettings settings, + string currentPath, + string candidatePath, + string effectivePath) + { + var existingLegacyPath = NormalizePath(settings.CasConfiguration.LegacyInstallationPoolRootPath); + var previousPath = !string.IsNullOrWhiteSpace(currentPath) + ? currentPath + : candidatePath; + + if (!string.IsNullOrWhiteSpace(effectivePath) && + string.Equals(previousPath, effectivePath, PathHelper.PathComparison)) + { + return existingLegacyPath.Equals(effectivePath, PathHelper.PathComparison) + ? string.Empty + : existingLegacyPath; + } + + return Directory.Exists(previousPath) + ? previousPath + : existingLegacyPath; + } + + private static string NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + try + { + return Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or IOException or SecurityException) + { + return string.Empty; + } + } +} diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs index 513225691..910521b1a 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs @@ -26,6 +26,7 @@ public static IServiceCollection AddCasServices(this IServiceCollection services // Pool management services (must be registered first for CasService to use) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // CAS integration services services.AddSingleton(); @@ -40,6 +41,8 @@ public static IServiceCollection AddCasServices(this IServiceCollection services config.EnableAutomaticGc = userCasConfig.EnableAutomaticGc; config.CasRootPath = userCasConfig.CasRootPath; config.InstallationPoolRootPath = userCasConfig.InstallationPoolRootPath; + config.IsInstallationPoolRootPathAutoDerived = userCasConfig.IsInstallationPoolRootPathAutoDerived; + config.LegacyInstallationPoolRootPath = userCasConfig.LegacyInstallationPoolRootPath; config.HashAlgorithm = userCasConfig.HashAlgorithm; config.GcGracePeriod = userCasConfig.GcGracePeriod; config.MaxCacheSizeBytes = userCasConfig.MaxCacheSizeBytes; From a885079620353f069db66f432dc21ee62048adc5 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 2 Aug 2026 14:19:56 +0100 Subject: [PATCH 3/9] fix(cas): harden writable pool fallback --- .../Common/IStorageWritabilityProbe.cs | 4 + .../ConfigurationProviderServiceTests.cs | 25 ++++ .../Services/StorageLocationServiceTests.cs | 20 +++ .../Services/UserSettingsServiceTests.cs | 34 ++++- .../Content/ContentOrchestratorTests.cs | 82 ++++++++++++ .../Storage/CasPoolWritabilityTests.cs | 11 +- .../InstallationCasPoolServiceTests.cs | 118 +++++++++++++++++- .../Services/ConfigurationProviderService.cs | 17 +-- .../Common/Services/StorageLocationService.cs | 4 +- .../CommunityOutpostDeliverer.cs | 26 ++-- .../Content/Services/ContentOrchestrator.cs | 13 +- .../Storage/Services/CasPoolManager.cs | 55 ++++---- .../Features/Storage/Services/CasService.cs | 10 +- .../Services/InstallationCasPoolService.cs | 14 +-- .../ConfigurationModule.cs | 3 - 15 files changed, 349 insertions(+), 87 deletions(-) diff --git a/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs b/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs index c407f0748..ad15dec52 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs @@ -8,6 +8,10 @@ public interface IStorageWritabilityProbe /// /// Checks whether a directory can be created at, or files written into, the given path. /// + /// + /// A successful check creates the storage directory when it does not already exist and leaves + /// that directory in place. Callers should account for this filesystem side effect. + /// /// The storage path to check. /// true when the location accepts writes; otherwise, false. bool CanCreateStorageAt(string storagePath); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs index 1233fa904..49836a2dc 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -3,6 +3,7 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Storage; using Microsoft.Extensions.Logging; using Moq; @@ -84,6 +85,30 @@ public void Constructor_WithNullLogger_ThrowsArgumentNullException() null!)); } + /// + /// Preserves every CAS option when applying the default primary pool path. + /// + [Fact] + public void GetCasConfiguration_WhenPrimaryPathIsEmpty_PreservesGcLockTimeout() + { + var expectedTimeout = TimeSpan.FromSeconds(91); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + CasRootPath = string.Empty, + GcLockTimeout = expectedTimeout, + }, + }; + _mockUserSettings.Setup(service => service.Get()).Returns(settings); + var provider = CreateProvider(); + + var result = provider.GetCasConfiguration(); + + Assert.Equal(expectedTimeout, result.GcLockTimeout); + Assert.False(string.IsNullOrWhiteSpace(result.CasRootPath)); + } + /// /// Verifies that GetWorkspacePath returns user setting when it's valid and directory exists. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs index dfbec8969..d1ad3b345 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/StorageLocationServiceTests.cs @@ -83,6 +83,26 @@ public void GetCasPoolPath_WhenConfiguredPathIsWritable_UsesConfiguredPool() Assert.Equal(configuredPath, result); } + /// + /// Keeps a dotted installation directory intact when resolving adjacent CAS storage. + /// + [Fact] + public void GetCasPoolPath_WhenInstallationDirectoryContainsDot_UsesFullDirectory() + { + var settings = new UserSettings { UseInstallationAdjacentStorage = true }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + var installationPath = Path.Combine(_tempPath, "ZeroHour v1.04"); + var installation = new GameInstallation(installationPath, GameInstallationType.Retail); + var adjacentPath = Path.Combine(installationPath, DirectoryNames.GenHubCasPool); + var probe = new Mock(); + probe.Setup(service => service.CanCreateStorageAt(adjacentPath)).Returns(true); + var service = CreateService(probe.Object); + + var result = service.GetCasPoolPath(installation); + + Assert.Equal(adjacentPath, result); + } + /// /// Uses installation-adjacent storage when its parent is writable. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs index 6ec506aae..687889d3f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs @@ -4,6 +4,7 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Storage; using Microsoft.Extensions.Logging; using Moq; @@ -131,6 +132,37 @@ public async Task LoadSettings_AfterSave_LoadsCorrectData() Assert.Equal(NavigationTab.Downloads, loadedSettings.LastSelectedTab); } + /// + /// Verifies that the historical installation-pool provenance marker survives settings persistence. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMarker() + { + var settingsPath = Path.Combine(_tempDirectory, "provenance", FileTypes.SettingsFileName); + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); + var service1 = new TestableUserSettingsService( + _mockLogger.Object, + CreateAppConfigMock(), + settingsPath); + service1.Update(settings => + { + settings.CasConfiguration.InstallationPoolRootPath = "/historical/installation/.genhub-cas"; + settings.MarkAsExplicitlySet(nameof(CasConfiguration.InstallationPoolRootPath)); + }); + await service1.SaveAsync(); + + var service2 = new TestableUserSettingsService( + _mockLogger.Object, + CreateAppConfigMock(), + settingsPath); + var loadedSettings = service2.Get(); + + Assert.Contains( + nameof(CasConfiguration.InstallationPoolRootPath), + loadedSettings.ExplicitlySetProperties); + } + /// /// Verifies that GetSettings returns default values with corrupted JSON. /// @@ -394,4 +426,4 @@ public TestableUserSettingsService(ILogger logger, IAppConf SetSettingsFilePath(settingsFilePath); } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index 8681831a7..184356b5f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -4,6 +4,7 @@ using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Content; +using GenHub.Core.Models.GameInstallations; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.Content; @@ -11,6 +12,8 @@ using GenHub.Features.Content.Services; using Microsoft.Extensions.Logging; using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; +using GameInstallationType = GenHub.Core.Models.Enums.GameInstallationType; namespace GenHub.Tests.Core.Features.Content; @@ -144,4 +147,83 @@ public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully() _manifestPoolMock.Verify(m => m.AddManifestAsync(manifest, It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); _contentValidatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once); } + + /// + /// Stops GameClient acquisition when storage settings cannot be saved safely. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task AcquireContentAsync_WhenGameClientPoolCannotBeEnsured_ReturnsFailure() + { + var searchResult = new ContentSearchResult + { + Id = "1.0.genhub.gameclient.test", + Name = "Test Client", + ProviderName = "TestProvider", + }; + var manifest = new ContentManifest + { + Id = searchResult.Id, + Name = searchResult.Name, + ContentType = ContentType.GameClient, + }; + var providerMock = new Mock(); + providerMock.Setup(provider => provider.SourceName).Returns(searchResult.ProviderName); + providerMock + .Setup(provider => provider.GetValidatedContentAsync(searchResult.Id, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + providerMock + .Setup(provider => provider.PrepareContentAsync( + manifest, + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + _cacheMock + .Setup(cache => cache.GetAsync(manifest.Id.Value, It.IsAny())) + .ReturnsAsync((ContentManifest?)null); + _contentValidatorMock + .Setup(validator => validator.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(new ValidationResult(manifest.Id, [])); + _contentValidatorMock + .Setup(validator => validator.ValidateAllAsync( + It.IsAny(), + manifest, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new ValidationResult(manifest.Id, [])); + _manifestPoolMock + .Setup(pool => pool.IsManifestAcquiredAsync(manifest.Id, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(false)); + var installation = new GameInstallation("/game", GameInstallationType.Retail); + _installationServiceMock + .Setup(service => service.GetAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([installation])); + _installationCasPoolServiceMock + .Setup(service => service.EnsurePoolPathAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(false); + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [providerMock.Object], + [], + [], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + var result = await orchestrator.AcquireContentAsync(searchResult); + + Assert.False(result.Success); + _manifestPoolMock.Verify( + pool => pool.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs index 503eed37d..d6bca502d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs @@ -1,4 +1,5 @@ using GenHub.Common.Services; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; @@ -29,7 +30,7 @@ public CasPoolWritabilityTests() { _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); _primaryPoolPath = Path.Combine(_tempPath, "primary-pool"); - _installationPoolPath = Path.Combine(_tempPath, "Game", ".genhub-cas"); + _installationPoolPath = Path.Combine(_tempPath, "Game", DirectoryNames.GenHubCasPool); Directory.CreateDirectory(_primaryPoolPath); } @@ -136,8 +137,8 @@ public void StorageWritabilityProbe_WhenDirectoryDeniesWrites_ReturnsFalse() { var probe = new StorageWritabilityProbe(new Mock>().Object); - Assert.False(probe.CanCreateStorageAt(Path.Combine(lockedPath, ".genhub-cas"))); - Assert.True(probe.CanCreateStorageAt(Path.Combine(_primaryPoolPath, ".genhub-cas"))); + Assert.False(probe.CanCreateStorageAt(Path.Combine(lockedPath, DirectoryNames.GenHubCasPool))); + Assert.True(probe.CanCreateStorageAt(Path.Combine(_primaryPoolPath, DirectoryNames.GenHubCasPool))); } finally { @@ -154,11 +155,11 @@ public void StorageWritabilityProbe_WhenDirectoryDeniesWrites_ReturnsFalse() public void StorageWritabilityProbe_WhenLocationIsWritable_LeavesNoProbeFile() { var probe = new StorageWritabilityProbe(new Mock>().Object); - var targetPath = Path.Combine(_primaryPoolPath, ".genhub-cas"); + var targetPath = Path.Combine(_primaryPoolPath, DirectoryNames.GenHubCasPool); Assert.True(probe.CanCreateStorageAt(targetPath)); Assert.True(Directory.Exists(targetPath)); - Assert.Empty(Directory.GetFiles(targetPath, ".genhub-write-probe-*")); + Assert.Empty(Directory.GetFiles(targetPath, StorageConstants.WriteProbeFilePrefix + "*")); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs index 07973af53..37d5cee9a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Common; @@ -39,7 +40,7 @@ public InstallationCasPoolServiceTests() public async Task EnsurePoolPathAsync_WhenHistoricalPathIsUnwritable_PreservesLegacyLookup() { var installation = CreateInstallation(); - var poolPath = Path.Combine(installation.InstallationPath, ".genhub-cas"); + var poolPath = Path.Combine(installation.InstallationPath, DirectoryNames.GenHubCasPool); Directory.CreateDirectory(poolPath); var settings = new UserSettings { @@ -95,7 +96,7 @@ public async Task EnsurePoolPathAsync_WhenCustomPathIsConfigured_PreservesIt() public async Task EnsurePoolPathAsync_WhenAdjacentPathIsWritable_RecordsAutoDerivedProvenance() { var installation = CreateInstallation(); - var poolPath = Path.Combine(installation.InstallationPath, ".genhub-cas"); + var poolPath = Path.Combine(installation.InstallationPath, DirectoryNames.GenHubCasPool); var settings = new UserSettings(); ConfigureMutableSettings(settings); _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(poolPath)).Returns(true); @@ -110,6 +111,43 @@ public async Task EnsurePoolPathAsync_WhenAdjacentPathIsWritable_RecordsAutoDeri _poolManager.Verify(manager => manager.ReinitializeInstallationPool(), Times.Once); } + /// + /// Continues with primary storage when no installation is available. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EnsurePoolPathAsync_WhenNoInstallations_ContinuesWithPrimaryPool() + { + var service = CreateService(); + + var result = await service.EnsurePoolPathAsync([]); + + Assert.True(result); + _userSettingsService.Verify( + settings => settings.TryUpdateAndSaveAsync(It.IsAny>()), + Times.Never); + } + + /// + /// Keeps a dotted installation directory intact when deriving the adjacent pool path. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EnsurePoolPathAsync_WhenInstallationDirectoryContainsDot_UsesFullDirectory() + { + var installation = CreateInstallation("ZeroHour v1.04"); + var poolPath = Path.Combine(installation.InstallationPath, DirectoryNames.GenHubCasPool); + var settings = new UserSettings(); + ConfigureMutableSettings(settings); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(poolPath)).Returns(true); + var service = CreateService(); + + var result = await service.EnsurePoolPathAsync([installation]); + + Assert.True(result); + Assert.Equal(poolPath, settings.CasConfiguration.InstallationPoolRootPath); + } + /// /// Removes a cached installation pool from every enumeration path after it becomes unavailable. /// @@ -148,7 +186,7 @@ public void CasPoolManager_WhenInstallationPoolBecomesUnavailable_DiscardsCached Assert.Equal(3, manager.GetAllStorages().Count); settings.CasConfiguration.InstallationPoolRootPath = string.Empty; - manager.EnsureAllPoolsInitialized(); + manager.ReinitializeInstallationPool(); Assert.Equal(2, manager.GetAllStorages().Count); Assert.Same(manager.GetStorage(CasPoolType.Primary), manager.GetStorage(CasPoolType.Installation)); @@ -228,6 +266,76 @@ public async Task CasService_GetContentPathAsync_FindsContentInLegacyPool() Assert.Equal(expectedPath, result.Data); } + /// + /// Does not expose a legacy CAS pool inside the application directory. + /// + [Fact] + public void CasPoolManager_WhenLegacyPoolIsInsideApplicationDirectory_BlocksIt() + { + var primaryPath = Path.Combine(_tempPath, "primary-security"); + Directory.CreateDirectory(primaryPath); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + LegacyInstallationPoolRootPath = AppContext.BaseDirectory, + }, + }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + var configuration = new CasConfiguration { CasRootPath = primaryPath }; + var resolver = new CasPoolResolver( + Options.Create(configuration), + _userSettingsService.Object, + _writabilityProbe.Object, + NullLogger.Instance); + + var manager = new CasPoolManager( + resolver, + Options.Create(configuration), + new Mock().Object, + NullLoggerFactory.Instance, + _writabilityProbe.Object, + NullLogger.Instance); + + Assert.Single(manager.GetAllStorages()); + Assert.StartsWith( + primaryPath, + manager.GetStorage(CasPoolType.Primary).GetObjectPath(new string('a', 64)), + StringComparison.Ordinal); + } + + /// + /// Avoids refreshing installation pools during ordinary cached storage lookups. + /// + [Fact] + public void CasPoolManager_WhenPrimaryStorageIsCached_DoesNotRefreshInstallationPools() + { + var primaryPath = Path.Combine(_tempPath, "primary-cached"); + Directory.CreateDirectory(primaryPath); + var resolver = new Mock(); + resolver + .Setup(service => service.GetPoolRootPath(CasPoolType.Primary)) + .Returns(primaryPath); + resolver.Setup(service => service.IsInstallationPoolAvailable()).Returns(false); + resolver.Setup(service => service.GetLegacyInstallationPoolRootPath()).Returns(string.Empty); + var configuration = new CasConfiguration { CasRootPath = primaryPath }; + var manager = new CasPoolManager( + resolver.Object, + Options.Create(configuration), + new Mock().Object, + NullLoggerFactory.Instance, + _writabilityProbe.Object, + NullLogger.Instance); + resolver.Invocations.Clear(); + + manager.GetStorage(CasPoolType.Primary); + manager.GetStorage(CasPoolType.Primary); + manager.GetAllStorages(); + + resolver.Verify(service => service.IsInstallationPoolAvailable(), Times.Never); + resolver.Verify(service => service.GetLegacyInstallationPoolRootPath(), Times.Never); + } + /// public void Dispose() { @@ -235,9 +343,9 @@ public void Dispose() GC.SuppressFinalize(this); } - private GameInstallation CreateInstallation() + private GameInstallation CreateInstallation(string directoryName = "Game") { - var installationPath = Path.Combine(_tempPath, "Game"); + var installationPath = Path.Combine(_tempPath, directoryName); Directory.CreateDirectory(installationPath); return new GameInstallation(installationPath, GameInstallationType.Steam); } diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs index 337711ed5..948cc7975 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -317,20 +317,9 @@ public CasConfiguration GetCasConfiguration() AppConstants.AppName, DirectoryNames.CasPool); - return new CasConfiguration - { - CasRootPath = defaultPath, - InstallationPoolRootPath = casConfig.InstallationPoolRootPath, - IsInstallationPoolRootPathAutoDerived = casConfig.IsInstallationPoolRootPathAutoDerived, - LegacyInstallationPoolRootPath = casConfig.LegacyInstallationPoolRootPath, - EnableAutomaticGc = casConfig.EnableAutomaticGc, - HashAlgorithm = casConfig.HashAlgorithm, - GcGracePeriod = casConfig.GcGracePeriod, - MaxCacheSizeBytes = casConfig.MaxCacheSizeBytes, - AutoGcInterval = casConfig.AutoGcInterval, - MaxConcurrentOperations = casConfig.MaxConcurrentOperations, - VerifyIntegrity = casConfig.VerifyIntegrity, - }; + var defaultConfig = (CasConfiguration)casConfig.Clone(); + defaultConfig.CasRootPath = defaultPath; + return defaultConfig; } return casConfig; diff --git a/GenHub/GenHub/Common/Services/StorageLocationService.cs b/GenHub/GenHub/Common/Services/StorageLocationService.cs index 959d8fbf4..8a345af2e 100644 --- a/GenHub/GenHub/Common/Services/StorageLocationService.cs +++ b/GenHub/GenHub/Common/Services/StorageLocationService.cs @@ -38,9 +38,7 @@ public string GetCasPoolPath(IGameInstallation installation) if (settings.UseInstallationAdjacentStorage) { - var installationPath = Path.HasExtension(installation.InstallationPath) - ? Path.GetDirectoryName(installation.InstallationPath) - : installation.InstallationPath; + var installationPath = installation.InstallationPath; if (!string.IsNullOrWhiteSpace(installationPath)) { var adjacentPath = Path.Combine(installationPath, DirectoryNames.GenHubCasPool); diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index 53dd11bb3..be8788503 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -331,7 +331,12 @@ await ProcessAndMergeDependencyBigFilesAsync( var hasGameClientManifest = manifests.Any(m => m.ContentType == ContentType.GameClient); if (hasGameClientManifest) { - await EnsureInstallationPoolPathAsync(cancellationToken); + var poolPathReady = await EnsureInstallationPoolPathAsync(cancellationToken); + if (!poolPathReady) + { + return OperationResult.CreateFailure( + "Could not ensure storage for GameClient content."); + } } foreach (var manifest in manifests) @@ -612,7 +617,8 @@ bool EndsWithSegment(string path, string segment) /// Ensures the InstallationPoolRootPath is set before storing GameClient content. /// This prevents content from being stored in the wrong CAS pool. /// - private async Task EnsureInstallationPoolPathAsync(CancellationToken cancellationToken) + /// true when content acquisition may continue; otherwise, false. + private async Task EnsureInstallationPoolPathAsync(CancellationToken cancellationToken) { try { @@ -626,23 +632,19 @@ private async Task EnsureInstallationPoolPathAsync(CancellationToken cancellatio var installationsResult = await installationService.GetAllInstallationsAsync(cancellationToken); if (!installationsResult.Success || installationsResult.Data == null) { - logger.LogWarning("Failed to get installations for CAS pool path resolution: {Error}", installationsResult.FirstError); - return; + logger.LogWarning( + "Failed to get installations for CAS pool path resolution: {Error}; the primary CAS pool will be used", + installationsResult.FirstError); + return true; } var installations = installationsResult.Data.ToList(); - - if (installations.Count == 0) - { - logger.LogWarning("No installations detected - cannot set InstallationPoolRootPath"); - return; - } - - await installationCasPoolService.EnsurePoolPathAsync(installations, cancellationToken); + return await installationCasPoolService.EnsurePoolPathAsync(installations, cancellationToken); } catch (Exception ex) { logger.LogError(ex, "Failed to ensure InstallationPoolRootPath is set"); + return false; } } diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs index 0b7d74322..20b9266f3 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs @@ -695,18 +695,13 @@ private async Task EnsureInstallationPoolPathAsync(CancellationToken cance var installationsResult = await _installationService.GetAllInstallationsAsync(cancellationToken); if (!installationsResult.Success || installationsResult.Data == null) { - _logger.LogWarning("Failed to get installations for CAS pool path resolution: {Error}", installationsResult.FirstError); - return false; + _logger.LogWarning( + "Failed to get installations for CAS pool path resolution: {Error}; the primary CAS pool will be used", + installationsResult.FirstError); + return true; } var installations = installationsResult.Data.ToList(); - - if (installations.Count == 0) - { - _logger.LogWarning("No installations detected - cannot set InstallationPoolRootPath"); - return false; - } - return await _installationCasPoolService.EnsurePoolPathAsync(installations, cancellationToken); } catch (Exception ex) diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index ac9ff93bd..37951381f 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -27,7 +27,7 @@ public class CasPoolManager : ICasPoolManager private readonly ConcurrentDictionary _storages = new(); private readonly object _initLock = new(); private string? _installationPoolRoot; - private ICasStorage? _legacyInstallationStorage; + private volatile ICasStorage? _legacyInstallationStorage; private string? _legacyInstallationPoolRoot; /// @@ -66,8 +66,6 @@ public CasPoolManager( /// public ICasStorage GetStorage(CasPoolType poolType) { - RefreshInstallationPools(); - if (_storages.TryGetValue(poolType, out var storage)) { _logger.LogDebug("Returning existing {PoolType} pool storage", poolType); @@ -117,11 +115,11 @@ public ICasStorage GetStorage(ContentType contentType) /// public IReadOnlyList GetAllStorages() { - RefreshInstallationPools(); var storages = _storages.Values.ToList(); - if (_legacyInstallationStorage != null && !storages.Contains(_legacyInstallationStorage)) + var legacyInstallationStorage = _legacyInstallationStorage; + if (legacyInstallationStorage != null && !storages.Contains(legacyInstallationStorage)) { - storages.Add(_legacyInstallationStorage); + storages.Add(legacyInstallationStorage); } return storages.AsReadOnly(); @@ -142,7 +140,10 @@ public void EnsureAllPoolsInitialized() InitializePool(CasPoolType.Primary); } - RefreshInstallationPools(); + if (!_storages.ContainsKey(CasPoolType.Installation) && _poolResolver.IsInstallationPoolAvailable()) + { + InitializePool(CasPoolType.Installation); + } } /// @@ -194,11 +195,7 @@ private void InitializePool(CasPoolType poolType) } // Security Guard: Prevent initializing CAS in the application directory or an empty path - var appBaseDir = Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory); - var normalizedRootPath = Path.TrimEndingDirectorySeparator(rootPath); - - if (normalizedRootPath.Equals(appBaseDir, StringComparison.OrdinalIgnoreCase) || - normalizedRootPath.StartsWith(appBaseDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + if (IsInsideApplicationDirectory(rootPath)) { _logger.LogError("Security Block: Attempted to initialize {PoolType} CAS pool at or inside the application directory: {Path}. This is not allowed.", poolType, rootPath); return; @@ -218,17 +215,8 @@ private void InitializePool(CasPoolType poolType) private ICasStorage CreateStorage(string rootPath) { - var poolConfig = new CasConfiguration - { - CasRootPath = rootPath, - HashAlgorithm = _config.HashAlgorithm, - GcGracePeriod = _config.GcGracePeriod, - MaxCacheSizeBytes = _config.MaxCacheSizeBytes, - AutoGcInterval = _config.AutoGcInterval, - MaxConcurrentOperations = _config.MaxConcurrentOperations, - VerifyIntegrity = _config.VerifyIntegrity, - EnableAutomaticGc = _config.EnableAutomaticGc, - }; + var poolConfig = (CasConfiguration)_config.Clone(); + poolConfig.CasRootPath = rootPath; return new CasStorage( Options.Create(poolConfig), @@ -276,6 +264,16 @@ private void RefreshLegacyInstallationPool(string activeInstallationRoot) } legacyRoot = Path.GetFullPath(legacyRoot); + if (IsInsideApplicationDirectory(legacyRoot)) + { + _legacyInstallationStorage = null; + _legacyInstallationPoolRoot = null; + _logger.LogError( + "Security Block: Attempted to retain a legacy CAS pool at or inside the application directory: {Path}. This is not allowed.", + legacyRoot); + return; + } + if (string.Equals(legacyRoot, _legacyInstallationPoolRoot, PathHelper.PathComparison)) { return; @@ -285,4 +283,15 @@ private void RefreshLegacyInstallationPool(string activeInstallationRoot) _legacyInstallationPoolRoot = legacyRoot; _logger.LogInformation("Retaining legacy installation CAS pool {LegacyRoot} for read-only lookup", legacyRoot); } + + 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/Storage/Services/CasService.cs b/GenHub/GenHub/Features/Storage/Services/CasService.cs index b1a7fadb9..fd372f464 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasService.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasService.cs @@ -574,7 +574,7 @@ public async Task> GetContentPathAsync( if (await fallbackStorage.ObjectExistsAsync(hash, cancellationToken)) { var path = fallbackStorage.GetObjectPath(hash); - logger.LogInformation("Found content {Hash} in a legacy CAS pool", hash); + logger.LogDebug("Found content {Hash} in a legacy CAS pool", hash); return OperationResult.CreateSuccess(path); } } @@ -608,13 +608,14 @@ public async Task> ExistsAsync( var storage = poolManager.GetStorage(contentType); var exists = await storage.ObjectExistsAsync(hash, cancellationToken); + ICasStorage? primaryStorage = null; if (!exists) { // Not found in the pool for this content type // As a fallback, check if it exists in the primary pool (may have been stored there before pool routing was implemented) logger.LogDebug("Content {Hash} not found in {ContentType} pool, checking primary pool as fallback", hash, contentType); - var primaryStorage = poolManager.GetStorage(CasPoolType.Primary); + primaryStorage = poolManager.GetStorage(CasPoolType.Primary); exists = await primaryStorage.ObjectExistsAsync(hash, cancellationToken); if (exists) @@ -627,14 +628,15 @@ public async Task> ExistsAsync( { foreach (var fallbackStorage in poolManager.GetAllStorages()) { - if (ReferenceEquals(fallbackStorage, storage)) + if (ReferenceEquals(fallbackStorage, storage) || + ReferenceEquals(fallbackStorage, primaryStorage)) { continue; } if (await fallbackStorage.ObjectExistsAsync(hash, cancellationToken)) { - logger.LogInformation("Found content {Hash} in a legacy CAS pool", hash); + logger.LogDebug("Found content {Hash} in a legacy CAS pool", hash); exists = true; break; } diff --git a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs index b98a821d4..6a2591eb6 100644 --- a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs +++ b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs @@ -38,7 +38,7 @@ public async Task EnsurePoolPathAsync( if (installations.Count == 0) { logger.LogWarning("No installations detected; the primary CAS pool will be used"); - return false; + return true; } var preferredInstallation = installations.Count == 1 @@ -59,14 +59,14 @@ public async Task EnsurePoolPathAsync( logger.LogWarning( "Preferred installation {InstallationId} has no usable path; the primary CAS pool will be used", preferredInstallation.Id); - return false; + return true; } candidatePath = NormalizePath(candidatePath); if (string.IsNullOrWhiteSpace(candidatePath)) { logger.LogWarning("The derived installation CAS pool path is invalid; the primary pool will be used"); - return false; + return true; } var currentSettings = userSettingsService.Get(); @@ -74,6 +74,9 @@ public async Task EnsurePoolPathAsync( var currentPath = NormalizePath(configuredCurrentPath); var historicalAutoDerivedMarker = currentSettings.ExplicitlySetProperties.Contains(ExplicitInstallationPoolPathKey); + + // Older GenHub builds marked their automatically derived installation pool as "explicitly set". + // No user-facing setting wrote this nested key, so it is migration provenance rather than user intent. if (!string.IsNullOrWhiteSpace(configuredCurrentPath) && string.IsNullOrWhiteSpace(currentPath) && !currentSettings.CasConfiguration.IsInstallationPoolRootPathAutoDerived && @@ -167,11 +170,6 @@ public async Task EnsurePoolPathAsync( return null; } - if (Path.HasExtension(installationPath)) - { - installationPath = Path.GetDirectoryName(installationPath); - } - return string.IsNullOrWhiteSpace(installationPath) ? null : Path.Combine(installationPath, DirectoryNames.GenHubCasPool); diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs index f65e3b651..e22fc76d3 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs @@ -48,9 +48,6 @@ public static IServiceCollection AddConfigurationModule(this IServiceCollection bootstrapLoggerFactory.CreateLogger()); services.AddSingleton>(provider => bootstrapLoggerFactory.CreateLogger()); - services.AddSingleton>(provider => - bootstrapLoggerFactory.CreateLogger()); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 4a1d4f079dd44315176c96c43a36ef43f5fa0c88 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 2 Aug 2026 14:44:52 +0100 Subject: [PATCH 4/9] fix(cas): honor pool selection cancellation --- .../InstallationCasPoolServiceTests.cs | 27 +++++++++++++++++++ .../Services/InstallationCasPoolService.cs | 2 ++ 2 files changed, 29 insertions(+) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs index 37d5cee9a..bf8724e42 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -148,6 +148,33 @@ public async Task EnsurePoolPathAsync_WhenInstallationDirectoryContainsDot_UsesF Assert.Equal(poolPath, settings.CasConfiguration.InstallationPoolRootPath); } + /// + /// Honors cancellation that arrives while resolving the pool and does not persist settings. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EnsurePoolPathAsync_WhenCancelledBeforeSave_DoesNotPersistSettings() + { + var installation = CreateInstallation(); + var poolPath = Path.Combine(installation.InstallationPath, DirectoryNames.GenHubCasPool); + var settings = new UserSettings(); + ConfigureMutableSettings(settings); + using var cancellationSource = new CancellationTokenSource(); + _writabilityProbe + .Setup(probe => probe.CanCreateStorageAt(poolPath)) + .Callback(cancellationSource.Cancel) + .Returns(true); + var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.EnsurePoolPathAsync([installation], cancellationSource.Token)); + + _userSettingsService.Verify( + userSettingsService => userSettingsService.TryUpdateAndSaveAsync(It.IsAny>()), + Times.Never); + _poolManager.Verify(manager => manager.ReinitializeInstallationPool(), Times.Never); + } + /// /// Removes a cached installation pool from every enumeration path after it becomes unavailable. /// diff --git a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs index 6a2591eb6..5edbdaf01 100644 --- a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs +++ b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs @@ -34,6 +34,7 @@ public async Task EnsurePoolPathAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(installations); + cancellationToken.ThrowIfCancellationRequested(); if (installations.Count == 0) { @@ -123,6 +124,7 @@ public async Task EnsurePoolPathAsync( return true; } + cancellationToken.ThrowIfCancellationRequested(); var saved = await userSettingsService.TryUpdateAndSaveAsync(settings => { settings.CasConfiguration.InstallationPoolRootPath = effectivePath; From 047d0abfeed49ed3b7354463a31b683cac0478fa Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 2 Aug 2026 22:24:14 +0100 Subject: [PATCH 5/9] fix(cas): normalize legacy pool roots --- .../Services/UserSettingsServiceTests.cs | 4 +- .../InstallationCasPoolServiceTests.cs | 38 +++++++++++++++++++ .../Storage/Services/CasPoolManager.cs | 15 ++++++-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs index 687889d3f..8c666a17a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs @@ -140,6 +140,7 @@ public async Task LoadSettings_AfterSave_LoadsCorrectData() public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMarker() { var settingsPath = Path.Combine(_tempDirectory, "provenance", FileTypes.SettingsFileName); + var historicalPoolPath = "/historical/installation/.genhub-cas"; Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); var service1 = new TestableUserSettingsService( _mockLogger.Object, @@ -147,7 +148,7 @@ public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMark settingsPath); service1.Update(settings => { - settings.CasConfiguration.InstallationPoolRootPath = "/historical/installation/.genhub-cas"; + settings.CasConfiguration.InstallationPoolRootPath = historicalPoolPath; settings.MarkAsExplicitlySet(nameof(CasConfiguration.InstallationPoolRootPath)); }); await service1.SaveAsync(); @@ -161,6 +162,7 @@ public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMark Assert.Contains( nameof(CasConfiguration.InstallationPoolRootPath), loadedSettings.ExplicitlySetProperties); + Assert.Equal(historicalPoolPath, loadedSettings.CasConfiguration.InstallationPoolRootPath); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs index bf8724e42..df8b0881b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -219,6 +219,44 @@ public void CasPoolManager_WhenInstallationPoolBecomesUnavailable_DiscardsCached Assert.Same(manager.GetStorage(CasPoolType.Primary), manager.GetStorage(CasPoolType.Installation)); } + /// + /// Does not retain the active installation pool as a duplicate legacy pool when path formatting differs. + /// + [Fact] + public void CasPoolManager_WhenLegacyRootMatchesActiveRoot_DoesNotRetainDuplicateStorage() + { + var primaryPath = Path.Combine(_tempPath, "primary-normalized"); + var installationPath = Path.Combine(_tempPath, "installation-normalized"); + Directory.CreateDirectory(primaryPath); + Directory.CreateDirectory(installationPath); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + InstallationPoolRootPath = installationPath, + LegacyInstallationPoolRootPath = installationPath + Path.DirectorySeparatorChar, + }, + }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(installationPath)).Returns(true); + var configuration = new CasConfiguration { CasRootPath = primaryPath }; + var resolver = new CasPoolResolver( + Options.Create(configuration), + _userSettingsService.Object, + _writabilityProbe.Object, + NullLogger.Instance); + + var manager = new CasPoolManager( + resolver, + Options.Create(configuration), + new Mock().Object, + NullLoggerFactory.Instance, + _writabilityProbe.Object, + NullLogger.Instance); + + Assert.Equal(2, manager.GetAllStorages().Count); + } + /// /// Reads an existing legacy object without attempting to create writable CAS directories. /// diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index 37951381f..51f75b17d 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -255,15 +255,24 @@ private void RefreshLegacyInstallationPool(string activeInstallationRoot) { var legacyRoot = _poolResolver.GetLegacyInstallationPoolRootPath(); if (string.IsNullOrWhiteSpace(legacyRoot) || - !Directory.Exists(legacyRoot) || - string.Equals(legacyRoot, activeInstallationRoot, PathHelper.PathComparison)) + !Directory.Exists(legacyRoot)) + { + _legacyInstallationStorage = null; + _legacyInstallationPoolRoot = null; + return; + } + + legacyRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(legacyRoot)); + var normalizedActiveRoot = string.IsNullOrWhiteSpace(activeInstallationRoot) + ? string.Empty + : Path.TrimEndingDirectorySeparator(Path.GetFullPath(activeInstallationRoot)); + if (string.Equals(legacyRoot, normalizedActiveRoot, PathHelper.PathComparison)) { _legacyInstallationStorage = null; _legacyInstallationPoolRoot = null; return; } - legacyRoot = Path.GetFullPath(legacyRoot); if (IsInsideApplicationDirectory(legacyRoot)) { _legacyInstallationStorage = null; From 140f798e8e584fadf7b4857cba40ed153ffb0a97 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 2 Aug 2026 23:33:43 +0100 Subject: [PATCH 6/9] fix(cas): avoid duplicate primary legacy storage --- .../InstallationCasPoolServiceTests.cs | 34 +++++++++++++++++++ .../Storage/Services/CasPoolManager.cs | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs index df8b0881b..e1a6f55d3 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -257,6 +257,40 @@ public void CasPoolManager_WhenLegacyRootMatchesActiveRoot_DoesNotRetainDuplicat Assert.Equal(2, manager.GetAllStorages().Count); } + /// + /// Does not retain the primary pool as a duplicate legacy pool when path formatting differs. + /// + [Fact] + public void CasPoolManager_WhenLegacyRootMatchesPrimaryRoot_DoesNotRetainDuplicateStorage() + { + var primaryPath = Path.Combine(_tempPath, "primary-legacy-normalized"); + Directory.CreateDirectory(primaryPath); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + LegacyInstallationPoolRootPath = primaryPath + Path.DirectorySeparatorChar, + }, + }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + var configuration = new CasConfiguration { CasRootPath = primaryPath }; + var resolver = new CasPoolResolver( + Options.Create(configuration), + _userSettingsService.Object, + _writabilityProbe.Object, + NullLogger.Instance); + + var manager = new CasPoolManager( + resolver, + Options.Create(configuration), + new Mock().Object, + NullLoggerFactory.Instance, + _writabilityProbe.Object, + NullLogger.Instance); + + Assert.Single(manager.GetAllStorages()); + } + /// /// Reads an existing legacy object without attempting to create writable CAS directories. /// diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index 51f75b17d..83534d4e1 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -266,7 +266,11 @@ private void RefreshLegacyInstallationPool(string activeInstallationRoot) var normalizedActiveRoot = string.IsNullOrWhiteSpace(activeInstallationRoot) ? string.Empty : Path.TrimEndingDirectorySeparator(Path.GetFullPath(activeInstallationRoot)); - if (string.Equals(legacyRoot, normalizedActiveRoot, PathHelper.PathComparison)) + var normalizedPrimaryRoot = string.IsNullOrWhiteSpace(_config.CasRootPath) + ? string.Empty + : Path.TrimEndingDirectorySeparator(Path.GetFullPath(_config.CasRootPath)); + if (string.Equals(legacyRoot, normalizedActiveRoot, PathHelper.PathComparison) || + string.Equals(legacyRoot, normalizedPrimaryRoot, PathHelper.PathComparison)) { _legacyInstallationStorage = null; _legacyInstallationPoolRoot = null; From cca029966e912dc958b5296cf8aa421c67fd3cf0 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 3 Aug 2026 00:26:15 +0100 Subject: [PATCH 7/9] test(cas): tolerate cleanup failures in installation pool tests --- .../Storage/InstallationCasPoolServiceTests.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs index e1a6f55d3..577b543f4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -438,7 +438,19 @@ public void CasPoolManager_WhenPrimaryStorageIsCached_DoesNotRefreshInstallation /// public void Dispose() { - Directory.Delete(_tempPath, true); + try + { + Directory.Delete(_tempPath, true); + } + catch (IOException) + { + // Best-effort cleanup for temporary test files. + } + catch (UnauthorizedAccessException) + { + // Best-effort cleanup for temporary test files. + } + GC.SuppressFinalize(this); } From 84c836dc6da51bea4e6a6a77c14ec8f694f6fa9a Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 3 Aug 2026 00:26:15 +0100 Subject: [PATCH 8/9] refactor(cas): drop a redundant installation-path check --- .../Features/Storage/Services/InstallationCasPoolService.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs index 5edbdaf01..9146d3c4f 100644 --- a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs +++ b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs @@ -167,11 +167,6 @@ public async Task EnsurePoolPathAsync( ? installation.ZeroHourPath : installation.GeneralsPath; - if (string.IsNullOrWhiteSpace(installationPath)) - { - return null; - } - return string.IsNullOrWhiteSpace(installationPath) ? null : Path.Combine(installationPath, DirectoryNames.GenHubCasPool); From 7fb0a1b5bfd04f807ed3a18abd2f72f59d182036 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 3 Aug 2026 01:27:45 +0100 Subject: [PATCH 9/9] fix(cas): retain every previous installation pool root for lookup --- .../Interfaces/Storage/ICasPoolResolver.cs | 6 +- .../Models/Storage/CasConfiguration.cs | 10 +- .../Storage/CasPoolWritabilityTests.cs | 6 +- .../InstallationCasPoolServiceTests.cs | 92 +++++++++++++++++-- .../Storage/Services/CasPoolManager.cs | 92 +++++++++++-------- .../Storage/Services/CasPoolResolver.cs | 30 ++++-- .../Services/InstallationCasPoolService.cs | 51 ++++++---- .../DependencyInjection/CasModule.cs | 2 +- 8 files changed, 209 insertions(+), 80 deletions(-) diff --git a/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs b/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs index 46a89d2c0..781dd68c4 100644 --- a/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs +++ b/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs @@ -29,10 +29,10 @@ public interface ICasPoolResolver string GetPoolRootPath(ContentType contentType); /// - /// Gets the previous installation-pool root retained for read-only lookup. + /// Gets the previous installation-pool roots retained for read-only lookup. /// - /// The legacy root, or an empty string when none is configured. - string GetLegacyInstallationPoolRootPath(); + /// The legacy roots, or an empty list when none are configured. + IReadOnlyList GetLegacyInstallationPoolRootPaths(); /// /// Checks if the installation pool is configured and available. diff --git a/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs b/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs index e6d16f55b..f32dded47 100644 --- a/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs +++ b/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs @@ -52,10 +52,12 @@ public TimeSpan GcLockTimeout public bool IsInstallationPoolRootPathAutoDerived { get; set; } /// - /// Gets or sets a previous installation-pool root that remains available for read-only - /// object lookup after new writes have fallen back to another pool. + /// Gets or sets the previous installation-pool roots that remain available for read-only + /// object lookup after new writes have fallen back to another pool. Every root the pool has + /// previously used is retained, because objects written to any of them stay reachable only + /// through this list. /// - public string LegacyInstallationPoolRootPath { get; set; } = string.Empty; + public List LegacyInstallationPoolRootPaths { get; set; } = []; /// /// Gets or sets the hash algorithm to use for content addressing. @@ -146,7 +148,7 @@ public object Clone() CasRootPath = CasRootPath, InstallationPoolRootPath = InstallationPoolRootPath, IsInstallationPoolRootPathAutoDerived = IsInstallationPoolRootPathAutoDerived, - LegacyInstallationPoolRootPath = LegacyInstallationPoolRootPath, + LegacyInstallationPoolRootPaths = [.. LegacyInstallationPoolRootPaths], HashAlgorithm = HashAlgorithm, GcGracePeriod = GcGracePeriod, MaxCacheSizeBytes = MaxCacheSizeBytes, diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs index d6bca502d..03b9a6ac9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs @@ -50,15 +50,15 @@ public void IsInstallationPoolAvailable_WhenPoolIsNotWritable_ReturnsFalse() /// Exposes an existing unwritable pool for read-only lookup before settings migration runs. /// [Fact] - public void GetLegacyInstallationPoolRootPath_WhenCurrentPoolIsUnwritable_ReturnsCurrentPath() + public void GetLegacyInstallationPoolRootPaths_WhenCurrentPoolIsUnwritable_ReturnsCurrentPath() { Directory.CreateDirectory(_installationPoolPath); _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(_installationPoolPath)).Returns(false); var resolver = CreateResolver(_installationPoolPath); - var result = resolver.GetLegacyInstallationPoolRootPath(); + var result = resolver.GetLegacyInstallationPoolRootPaths(); - Assert.Equal(_installationPoolPath, result); + Assert.Equal([_installationPoolPath], result); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs index 577b543f4..1e54fa3c9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -55,7 +55,7 @@ public async Task EnsurePoolPathAsync_WhenHistoricalPathIsUnwritable_PreservesLe Assert.True(result); Assert.Empty(settings.CasConfiguration.InstallationPoolRootPath); - Assert.Equal(poolPath, settings.CasConfiguration.LegacyInstallationPoolRootPath); + Assert.Equal([poolPath], settings.CasConfiguration.LegacyInstallationPoolRootPaths); Assert.False(settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived); Assert.DoesNotContain(nameof(CasConfiguration.InstallationPoolRootPath), settings.ExplicitlySetProperties); _poolManager.Verify(manager => manager.ReinitializeInstallationPool(), Times.Once); @@ -192,7 +192,7 @@ public void CasPoolManager_WhenInstallationPoolBecomesUnavailable_DiscardsCached CasConfiguration = new CasConfiguration { InstallationPoolRootPath = installationPath, - LegacyInstallationPoolRootPath = legacyPath, + LegacyInstallationPoolRootPaths = [legacyPath], }, }; _userSettingsService.Setup(service => service.Get()).Returns(settings); @@ -234,7 +234,7 @@ public void CasPoolManager_WhenLegacyRootMatchesActiveRoot_DoesNotRetainDuplicat CasConfiguration = new CasConfiguration { InstallationPoolRootPath = installationPath, - LegacyInstallationPoolRootPath = installationPath + Path.DirectorySeparatorChar, + LegacyInstallationPoolRootPaths = [installationPath + Path.DirectorySeparatorChar], }, }; _userSettingsService.Setup(service => service.Get()).Returns(settings); @@ -269,7 +269,7 @@ public void CasPoolManager_WhenLegacyRootMatchesPrimaryRoot_DoesNotRetainDuplica { CasConfiguration = new CasConfiguration { - LegacyInstallationPoolRootPath = primaryPath + Path.DirectorySeparatorChar, + LegacyInstallationPoolRootPaths = [primaryPath + Path.DirectorySeparatorChar], }, }; _userSettingsService.Setup(service => service.Get()).Returns(settings); @@ -332,7 +332,7 @@ public async Task CasService_GetContentPathAsync_FindsContentInLegacyPool() { CasConfiguration = new CasConfiguration { - LegacyInstallationPoolRootPath = legacyPath, + LegacyInstallationPoolRootPaths = [legacyPath], }, }; _userSettingsService.Setup(service => service.Get()).Returns(settings); @@ -377,7 +377,7 @@ public void CasPoolManager_WhenLegacyPoolIsInsideApplicationDirectory_BlocksIt() { CasConfiguration = new CasConfiguration { - LegacyInstallationPoolRootPath = AppContext.BaseDirectory, + LegacyInstallationPoolRootPaths = [AppContext.BaseDirectory], }, }; _userSettingsService.Setup(service => service.Get()).Returns(settings); @@ -416,7 +416,7 @@ public void CasPoolManager_WhenPrimaryStorageIsCached_DoesNotRefreshInstallation .Setup(service => service.GetPoolRootPath(CasPoolType.Primary)) .Returns(primaryPath); resolver.Setup(service => service.IsInstallationPoolAvailable()).Returns(false); - resolver.Setup(service => service.GetLegacyInstallationPoolRootPath()).Returns(string.Empty); + resolver.Setup(service => service.GetLegacyInstallationPoolRootPaths()).Returns([]); var configuration = new CasConfiguration { CasRootPath = primaryPath }; var manager = new CasPoolManager( resolver.Object, @@ -432,7 +432,7 @@ public void CasPoolManager_WhenPrimaryStorageIsCached_DoesNotRefreshInstallation manager.GetAllStorages(); resolver.Verify(service => service.IsInstallationPoolAvailable(), Times.Never); - resolver.Verify(service => service.GetLegacyInstallationPoolRootPath(), Times.Never); + resolver.Verify(service => service.GetLegacyInstallationPoolRootPaths(), Times.Never); } /// @@ -461,6 +461,82 @@ private GameInstallation CreateInstallation(string directoryName = "Game") return new GameInstallation(installationPath, GameInstallationType.Steam); } + /// + /// Retains every previously used pool root when the pool moves more than once, because nothing + /// copies objects out of a root that is replaced. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EnsurePoolPathAsync_WhenPoolMovesAgain_RetainsEveryPreviousRoot() + { + var firstLegacyPath = Path.Combine(_tempPath, "first-legacy"); + var currentInstallation = CreateInstallation("CurrentGame"); + var currentPoolPath = Path.Combine(currentInstallation.InstallationPath, DirectoryNames.GenHubCasPool); + var nextInstallation = CreateInstallation("NextGame"); + var nextPoolPath = Path.Combine(nextInstallation.InstallationPath, DirectoryNames.GenHubCasPool); + Directory.CreateDirectory(firstLegacyPath); + Directory.CreateDirectory(currentPoolPath); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + InstallationPoolRootPath = currentPoolPath, + IsInstallationPoolRootPathAutoDerived = true, + LegacyInstallationPoolRootPaths = [firstLegacyPath], + }, + }; + ConfigureMutableSettings(settings); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(nextPoolPath)).Returns(true); + var service = CreateService(); + + var result = await service.EnsurePoolPathAsync([nextInstallation]); + + Assert.True(result); + Assert.Equal(nextPoolPath, settings.CasConfiguration.InstallationPoolRootPath); + Assert.Equal( + [firstLegacyPath, currentPoolPath], + settings.CasConfiguration.LegacyInstallationPoolRootPaths); + } + + /// + /// Exposes every retained legacy root for read-only lookup rather than only the most recent one. + /// + [Fact] + public void CasPoolManager_WhenMultipleLegacyRootsAreRetained_ExposesEachForLookup() + { + var primaryPath = Path.Combine(_tempPath, "primary-multi"); + var firstLegacyPath = Path.Combine(_tempPath, "legacy-one"); + var secondLegacyPath = Path.Combine(_tempPath, "legacy-two"); + Directory.CreateDirectory(primaryPath); + Directory.CreateDirectory(firstLegacyPath); + Directory.CreateDirectory(secondLegacyPath); + var settings = new UserSettings + { + CasConfiguration = new CasConfiguration + { + LegacyInstallationPoolRootPaths = [firstLegacyPath, secondLegacyPath], + }, + }; + _userSettingsService.Setup(service => service.Get()).Returns(settings); + var configuration = new CasConfiguration { CasRootPath = primaryPath }; + var resolver = new CasPoolResolver( + Options.Create(configuration), + _userSettingsService.Object, + _writabilityProbe.Object, + NullLogger.Instance); + + var manager = new CasPoolManager( + resolver, + Options.Create(configuration), + new Mock().Object, + NullLoggerFactory.Instance, + _writabilityProbe.Object, + NullLogger.Instance); + + // The primary pool plus both retained legacy roots. + Assert.Equal(3, manager.GetAllStorages().Count); + } + private InstallationCasPoolService CreateService() { return new InstallationCasPoolService( diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs index 83534d4e1..0e020922f 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs @@ -27,8 +27,8 @@ public class CasPoolManager : ICasPoolManager private readonly ConcurrentDictionary _storages = new(); private readonly object _initLock = new(); private string? _installationPoolRoot; - private volatile ICasStorage? _legacyInstallationStorage; - private string? _legacyInstallationPoolRoot; + private volatile IReadOnlyList _legacyInstallationStorages = []; + private IReadOnlyList _legacyInstallationPoolRoots = []; /// /// Initializes a new instance of the class. @@ -116,10 +116,12 @@ public ICasStorage GetStorage(ContentType contentType) public IReadOnlyList GetAllStorages() { var storages = _storages.Values.ToList(); - var legacyInstallationStorage = _legacyInstallationStorage; - if (legacyInstallationStorage != null && !storages.Contains(legacyInstallationStorage)) + foreach (var legacyInstallationStorage in _legacyInstallationStorages) { - storages.Add(legacyInstallationStorage); + if (!storages.Contains(legacyInstallationStorage)) + { + storages.Add(legacyInstallationStorage); + } } return storages.AsReadOnly(); @@ -164,8 +166,8 @@ public void ReinitializeInstallationPool() } _installationPoolRoot = null; - _legacyInstallationStorage = null; - _legacyInstallationPoolRoot = null; + _legacyInstallationStorages = []; + _legacyInstallationPoolRoots = []; } RefreshInstallationPools(); @@ -253,48 +255,62 @@ private void RefreshInstallationPools() private void RefreshLegacyInstallationPool(string activeInstallationRoot) { - var legacyRoot = _poolResolver.GetLegacyInstallationPoolRootPath(); - if (string.IsNullOrWhiteSpace(legacyRoot) || - !Directory.Exists(legacyRoot)) - { - _legacyInstallationStorage = null; - _legacyInstallationPoolRoot = null; - return; - } + var normalizedActiveRoot = NormalizeRoot(activeInstallationRoot); + var normalizedPrimaryRoot = NormalizeRoot(_config.CasRootPath); + var retainedRoots = new List(); - legacyRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(legacyRoot)); - var normalizedActiveRoot = string.IsNullOrWhiteSpace(activeInstallationRoot) - ? string.Empty - : Path.TrimEndingDirectorySeparator(Path.GetFullPath(activeInstallationRoot)); - var normalizedPrimaryRoot = string.IsNullOrWhiteSpace(_config.CasRootPath) - ? string.Empty - : Path.TrimEndingDirectorySeparator(Path.GetFullPath(_config.CasRootPath)); - if (string.Equals(legacyRoot, normalizedActiveRoot, PathHelper.PathComparison) || - string.Equals(legacyRoot, normalizedPrimaryRoot, PathHelper.PathComparison)) + foreach (var configuredRoot in _poolResolver.GetLegacyInstallationPoolRootPaths()) { - _legacyInstallationStorage = null; - _legacyInstallationPoolRoot = null; - return; + if (string.IsNullOrWhiteSpace(configuredRoot) || !Directory.Exists(configuredRoot)) + { + continue; + } + + var legacyRoot = NormalizeRoot(configuredRoot); + + // Both pools are already reachable directly, so retaining them again would duplicate reads. + if (string.Equals(legacyRoot, normalizedActiveRoot, PathHelper.PathComparison) || + string.Equals(legacyRoot, normalizedPrimaryRoot, PathHelper.PathComparison)) + { + continue; + } + + if (IsInsideApplicationDirectory(legacyRoot)) + { + _logger.LogError( + "Security Block: Attempted to retain a legacy CAS pool at or inside the application directory: {Path}. This is not allowed.", + legacyRoot); + continue; + } + + if (!retainedRoots.Contains(legacyRoot, PathHelper.PathComparer)) + { + retainedRoots.Add(legacyRoot); + } } - if (IsInsideApplicationDirectory(legacyRoot)) + if (retainedRoots.SequenceEqual(_legacyInstallationPoolRoots, PathHelper.PathComparer)) { - _legacyInstallationStorage = null; - _legacyInstallationPoolRoot = null; - _logger.LogError( - "Security Block: Attempted to retain a legacy CAS pool at or inside the application directory: {Path}. This is not allowed.", - legacyRoot); return; } - if (string.Equals(legacyRoot, _legacyInstallationPoolRoot, PathHelper.PathComparison)) + _legacyInstallationStorages = retainedRoots.Select(CreateStorage).ToList(); + _legacyInstallationPoolRoots = retainedRoots; + + if (retainedRoots.Count > 0) { - return; + _logger.LogInformation( + "Retaining {Count} legacy installation CAS pool(s) for read-only lookup: {LegacyRoots}", + retainedRoots.Count, + string.Join(", ", retainedRoots)); } + } - _legacyInstallationStorage = CreateStorage(legacyRoot); - _legacyInstallationPoolRoot = legacyRoot; - _logger.LogInformation("Retaining legacy installation CAS pool {LegacyRoot} for read-only lookup", legacyRoot); + private static string NormalizeRoot(string? rootPath) + { + return string.IsNullOrWhiteSpace(rootPath) + ? string.Empty + : Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); } private bool IsInsideApplicationDirectory(string rootPath) diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs index 171f80652..c76c016b2 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.Linq; using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; @@ -73,20 +74,27 @@ public string GetPoolRootPath(ContentType contentType) } /// - public string GetLegacyInstallationPoolRootPath() + public IReadOnlyList GetLegacyInstallationPoolRootPaths() { var configuration = userSettingsService.Get().CasConfiguration; - if (!string.IsNullOrWhiteSpace(configuration.LegacyInstallationPoolRootPath)) + var roots = new List(); + + foreach (var configuredRoot in configuration.LegacyInstallationPoolRootPaths) { - return configuration.LegacyInstallationPoolRootPath; + AddRoot(roots, configuredRoot); } + // A configured pool that exists but cannot be written has not been migrated yet, so it + // still holds the only copy of any object written before it became unwritable. var currentPath = configuration.InstallationPoolRootPath; - return !string.IsNullOrWhiteSpace(currentPath) && + if (!string.IsNullOrWhiteSpace(currentPath) && Directory.Exists(currentPath) && - !writabilityProbe.CanCreateStorageAt(currentPath) - ? currentPath - : string.Empty; + !writabilityProbe.CanCreateStorageAt(currentPath)) + { + AddRoot(roots, currentPath); + } + + return roots; } /// @@ -113,6 +121,14 @@ public bool IsInstallationPoolAvailable() return false; } + private static void AddRoot(List roots, string? root) + { + if (!string.IsNullOrWhiteSpace(root) && !roots.Contains(root, PathHelper.PathComparer)) + { + roots.Add(root); + } + } + /// /// Gets the installation pool root path from UserSettings. /// Always reads current value from UserSettings (not cached). diff --git a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs index 9146d3c4f..c5df34d79 100644 --- a/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs +++ b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs @@ -109,14 +109,13 @@ public async Task EnsurePoolPathAsync( var candidateIsWritable = writabilityProbe.CanCreateStorageAt(candidatePath); var effectivePath = candidateIsWritable ? candidatePath : string.Empty; - var legacyPath = SelectLegacyPath(currentSettings, currentPath, candidatePath, effectivePath); + var legacyPaths = SelectLegacyPaths(currentSettings, currentPath, candidatePath, effectivePath); var settingsAlreadyMatch = string.Equals(currentPath, effectivePath, PathHelper.PathComparison) && currentSettings.CasConfiguration.IsInstallationPoolRootPathAutoDerived == candidateIsWritable && - string.Equals( - NormalizePath(currentSettings.CasConfiguration.LegacyInstallationPoolRootPath), - legacyPath, - PathHelper.PathComparison) && + currentSettings.CasConfiguration.LegacyInstallationPoolRootPaths + .Select(NormalizePath) + .SequenceEqual(legacyPaths, PathHelper.PathComparer) && string.Equals(currentSettings.PreferredStorageInstallationId, preferredInstallation.Id, StringComparison.Ordinal) && !currentSettings.ExplicitlySetProperties.Contains(ExplicitInstallationPoolPathKey); if (settingsAlreadyMatch) @@ -129,7 +128,7 @@ public async Task EnsurePoolPathAsync( { settings.CasConfiguration.InstallationPoolRootPath = effectivePath; settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived = candidateIsWritable; - settings.CasConfiguration.LegacyInstallationPoolRootPath = legacyPath; + settings.CasConfiguration.LegacyInstallationPoolRootPaths = legacyPaths; settings.PreferredStorageInstallationId = preferredInstallation.Id; settings.ExplicitlySetProperties.Remove(ExplicitInstallationPoolPathKey); return true; @@ -187,28 +186,48 @@ private static bool IsAutoDerived( derivedPaths.Contains(currentPath); } - private static string SelectLegacyPath( + private static List SelectLegacyPaths( UserSettings settings, string currentPath, string candidatePath, string effectivePath) { - var existingLegacyPath = NormalizePath(settings.CasConfiguration.LegacyInstallationPoolRootPath); + // Every root the pool has previously used is retained. Dropping one would strand the + // objects written to it, because nothing copies them into the pool that replaces it. + var retainedPaths = new List(); + foreach (var existingLegacyPath in settings.CasConfiguration.LegacyInstallationPoolRootPaths) + { + AddLegacyPath(retainedPaths, NormalizePath(existingLegacyPath), effectivePath); + } + var previousPath = !string.IsNullOrWhiteSpace(currentPath) ? currentPath : candidatePath; + if (Directory.Exists(previousPath)) + { + AddLegacyPath(retainedPaths, previousPath, effectivePath); + } + + return retainedPaths; + } - if (!string.IsNullOrWhiteSpace(effectivePath) && - string.Equals(previousPath, effectivePath, PathHelper.PathComparison)) + private static void AddLegacyPath(List retainedPaths, string path, string effectivePath) + { + if (string.IsNullOrWhiteSpace(path)) { - return existingLegacyPath.Equals(effectivePath, PathHelper.PathComparison) - ? string.Empty - : existingLegacyPath; + return; } - return Directory.Exists(previousPath) - ? previousPath - : existingLegacyPath; + // The pool that now takes writes is reachable directly, so it is never also a legacy root. + if (string.Equals(path, effectivePath, PathHelper.PathComparison)) + { + return; + } + + if (!retainedPaths.Contains(path, PathHelper.PathComparer)) + { + retainedPaths.Add(path); + } } private static string NormalizePath(string? path) diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs index 910521b1a..caf379c44 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/CasModule.cs @@ -42,7 +42,7 @@ public static IServiceCollection AddCasServices(this IServiceCollection services config.CasRootPath = userCasConfig.CasRootPath; config.InstallationPoolRootPath = userCasConfig.InstallationPoolRootPath; config.IsInstallationPoolRootPathAutoDerived = userCasConfig.IsInstallationPoolRootPathAutoDerived; - config.LegacyInstallationPoolRootPath = userCasConfig.LegacyInstallationPoolRootPath; + config.LegacyInstallationPoolRootPaths = [.. userCasConfig.LegacyInstallationPoolRootPaths]; config.HashAlgorithm = userCasConfig.HashAlgorithm; config.GcGracePeriod = userCasConfig.GcGracePeriod; config.MaxCacheSizeBytes = userCasConfig.MaxCacheSizeBytes;