diff --git a/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs b/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs new file mode 100644 index 000000000..ad15dec52 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs @@ -0,0 +1,24 @@ +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. + /// + /// + /// 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); + + /// + /// 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.Core/Interfaces/Storage/ICasPoolResolver.cs b/GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs index eac543d32..781dd68c4 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 roots retained for read-only lookup. + /// + /// 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/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..f32dded47 100644 --- a/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs +++ b/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs @@ -45,6 +45,20 @@ 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 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 List LegacyInstallationPoolRootPaths { get; set; } = []; + /// /// Gets or sets the hash algorithm to use for content addressing. /// @@ -133,6 +147,8 @@ public object Clone() EnableAutomaticGc = EnableAutomaticGc, CasRootPath = CasRootPath, InstallationPoolRootPath = InstallationPoolRootPath, + IsInstallationPoolRootPathAutoDerived = IsInstallationPoolRootPathAutoDerived, + LegacyInstallationPoolRootPaths = [.. LegacyInstallationPoolRootPaths], HashAlgorithm = HashAlgorithm, GcGracePeriod = GcGracePeriod, MaxCacheSizeBytes = MaxCacheSizeBytes, 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 67685d879..d1ad3b345 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,75 @@ 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); + } + + /// + /// 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); } /// @@ -188,9 +256,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/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs index 6ec506aae..8c666a17a 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,39 @@ 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); + var historicalPoolPath = "/historical/installation/.genhub-cas"; + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); + var service1 = new TestableUserSettingsService( + _mockLogger.Object, + CreateAppConfigMock(), + settingsPath); + service1.Update(settings => + { + settings.CasConfiguration.InstallationPoolRootPath = historicalPoolPath; + 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); + Assert.Equal(historicalPoolPath, loadedSettings.CasConfiguration.InstallationPoolRootPath); + } + /// /// Verifies that GetSettings returns default values with corrupted JSON. /// @@ -394,4 +428,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 fdbd98d2a..184356b5f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -2,7 +2,9 @@ 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.GameInstallations; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.Content; @@ -10,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; @@ -22,7 +26,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 _installationCasPoolServiceMock = default!; private readonly Mock> _loggerMock = default!; /// @@ -34,7 +38,7 @@ public ContentOrchestratorTests() _contentValidatorMock = new Mock(); _manifestPoolMock = new Mock(); _installationServiceMock = new Mock(); - _userSettingsServiceMock = new Mock(); + _installationCasPoolServiceMock = new Mock(); _loggerMock = new Mock>(); } @@ -71,7 +75,7 @@ public async Task SearchAsync_AggregatesResultsFromMultipleProviders_Successfull _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _userSettingsServiceMock.Object); + _installationCasPoolServiceMock.Object); // Act var result = await orchestrator.SearchAsync(new ContentSearchQuery()); @@ -132,7 +136,7 @@ public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully() _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _userSettingsServiceMock.Object); + _installationCasPoolServiceMock.Object); // Act var result = await orchestrator.AcquireContentAsync(searchResult); @@ -143,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 new file mode 100644 index 000000000..03b9a6ac9 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasPoolWritabilityTests.cs @@ -0,0 +1,203 @@ +using GenHub.Common.Services; +using GenHub.Core.Constants; +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", DirectoryNames.GenHubCasPool); + 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()); + } + + /// + /// Exposes an existing unwritable pool for read-only lookup before settings migration runs. + /// + [Fact] + public void GetLegacyInstallationPoolRootPaths_WhenCurrentPoolIsUnwritable_ReturnsCurrentPath() + { + Directory.CreateDirectory(_installationPoolPath); + _writabilityProbe.Setup(probe => probe.CanCreateStorageAt(_installationPoolPath)).Returns(false); + var resolver = CreateResolver(_installationPoolPath); + + var result = resolver.GetLegacyInstallationPoolRootPaths(); + + Assert.Equal([_installationPoolPath], result); + } + + /// + /// 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. + /// + /// 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) + { + _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, DirectoryNames.GenHubCasPool))); + Assert.True(probe.CanCreateStorageAt(Path.Combine(_primaryPoolPath, DirectoryNames.GenHubCasPool))); + } + 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); + var targetPath = Path.Combine(_primaryPoolPath, DirectoryNames.GenHubCasPool); + + Assert.True(probe.CanCreateStorageAt(targetPath)); + Assert.True(Directory.Exists(targetPath)); + Assert.Empty(Directory.GetFiles(targetPath, StorageConstants.WriteProbeFilePrefix + "*")); + } + + /// + 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.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..1e54fa3c9 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/InstallationCasPoolServiceTests.cs @@ -0,0 +1,556 @@ +using GenHub.Core.Constants; +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, DirectoryNames.GenHubCasPool); + 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.LegacyInstallationPoolRootPaths); + 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, 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); + Assert.True(settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived); + Assert.Equal(installation.Id, settings.PreferredStorageInstallationId); + _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); + } + + /// + /// 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. + /// + [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, + LegacyInstallationPoolRootPaths = [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.ReinitializeInstallationPool(); + + Assert.Equal(2, manager.GetAllStorages().Count); + 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, + LegacyInstallationPoolRootPaths = [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); + } + + /// + /// 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 + { + LegacyInstallationPoolRootPaths = [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. + /// + /// 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 + { + LegacyInstallationPoolRootPaths = [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); + } + + /// + /// 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 + { + LegacyInstallationPoolRootPaths = [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.GetLegacyInstallationPoolRootPaths()).Returns([]); + 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.GetLegacyInstallationPoolRootPaths(), Times.Never); + } + + /// + 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 GameInstallation CreateInstallation(string directoryName = "Game") + { + var installationPath = Path.Combine(_tempPath, directoryName); + Directory.CreateDirectory(installationPath); + 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( + _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 39352c787..948cc7975 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -317,17 +317,9 @@ public CasConfiguration GetCasConfiguration() AppConstants.AppName, DirectoryNames.CasPool); - return new CasConfiguration - { - CasRootPath = defaultPath, - 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 44b03f963..8a345af2e 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,41 @@ 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 = 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 +74,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 +180,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 +191,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 new file mode 100644 index 000000000..4fddd2c98 --- /dev/null +++ b/GenHub/GenHub/Common/Services/StorageWritabilityProbe.cs @@ -0,0 +1,124 @@ +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; + +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 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; + 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/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index 2fef1f6fd..be8788503 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -36,8 +36,7 @@ public class CommunityOutpostDeliverer( IContentManifestPool manifestPool, CommunityOutpostManifestFactory manifestFactory, IGameInstallationService installationService, - IUserSettingsService userSettingsService, - ICasPoolManager? casPoolManager, + IInstallationCasPoolService installationCasPoolService, CompressedImageToTgaConverter avifConverter, ILogger logger) : IContentDeliverer @@ -80,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. @@ -363,12 +331,12 @@ await ProcessAndMergeDependencyBigFilesAsync( var hasGameClientManifest = manifests.Any(m => m.ContentType == ContentType.GameClient); 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(); + var poolPathReady = await EnsureInstallationPoolPathAsync(cancellationToken); + if (!poolPathReady) + { + return OperationResult.CreateFailure( + "Could not ensure storage for GameClient content."); + } } foreach (var manifest in manifests) @@ -649,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 { @@ -663,82 +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; - } - - // 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 (!saved) - { - logger.LogError("Failed to save installation pool path settings for installation {InstallationId}", installation.Id); - } - - // Verify the setting was applied - var updatedSettings = userSettingsService.Get(); - logger.LogInformation("Verified InstallationPoolRootPath is now: {Path}", updatedSettings.CasConfiguration.InstallationPoolRootPath); - 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(); - - if (preferredInstallation != null) - { - 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); - } - } - else - { - logger.LogWarning("No valid installation found for CAS pool path resolution"); - } + 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 8c5474aa6..20b9266f3 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,39 +37,9 @@ public class ContentOrchestrator : IContentOrchestrator private readonly IContentValidator _contentValidator; private readonly IContentManifestPool _manifestPool; private readonly IGameInstallationService _installationService; - private readonly IUserSettingsService _userSettingsService; + 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. /// @@ -80,7 +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 installation CAS pool selector. public ContentOrchestrator( ILogger logger, IEnumerable providers, @@ -90,7 +61,7 @@ public ContentOrchestrator( IContentValidator contentValidator, IContentManifestPool manifestPool, IGameInstallationService installationService, - IUserSettingsService userSettingsService) + IInstallationCasPoolService installationCasPoolService) { _logger = logger; _providers = [.. providers]; @@ -108,7 +79,7 @@ public ContentOrchestrator( _contentValidator = contentValidator; _manifestPool = manifestPool; _installationService = installationService; - _userSettingsService = userSettingsService; + _installationCasPoolService = installationCasPoolService; _logger.LogInformation("ContentOrchestrator initialized with {ProviderCount} providers, {DiscovererCount} discoverers, {ResolverCount} resolvers", _providers.Count, _discoverers.Count, _resolvers.Count); } @@ -724,64 +695,14 @@ 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; - } - - // 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); - - return 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 - // 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(); - - if (preferredInstallation != null) - { - 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; - }); - } - } - - // Should not be reachable given the checks above - 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 45e0c28c3..0e020922f 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; @@ -21,9 +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 volatile IReadOnlyList _legacyInstallationStorages = []; + private IReadOnlyList _legacyInstallationPoolRoots = []; /// /// Initializes a new instance of the class. @@ -32,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(); } /// @@ -111,7 +115,16 @@ public ICasStorage GetStorage(ContentType contentType) /// public IReadOnlyList GetAllStorages() { - return _storages.Values.ToList().AsReadOnly(); + var storages = _storages.Values.ToList(); + foreach (var legacyInstallationStorage in _legacyInstallationStorages) + { + if (!storages.Contains(legacyInstallationStorage)) + { + storages.Add(legacyInstallationStorage); + } + } + + return storages.AsReadOnly(); } /// @@ -129,10 +142,8 @@ 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); } } @@ -145,21 +156,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; + _legacyInstallationStorages = []; + _legacyInstallationPoolRoots = []; } + + RefreshInstallationPools(); } private void InitializePool(CasPoolType poolType) @@ -172,50 +183,144 @@ 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 + 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; + } - 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; - } + var storage = CreateStorage(rootPath); + _storages.TryAdd(poolType, storage); - // 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); - - _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 ICasStorage CreateStorage(string rootPath) + { + var poolConfig = (CasConfiguration)_config.Clone(); + poolConfig.CasRootPath = rootPath; + + 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); + } + } + + private void RefreshLegacyInstallationPool(string activeInstallationRoot) + { + var normalizedActiveRoot = NormalizeRoot(activeInstallationRoot); + var normalizedPrimaryRoot = NormalizeRoot(_config.CasRootPath); + var retainedRoots = new List(); + + foreach (var configuredRoot in _poolResolver.GetLegacyInstallationPoolRootPaths()) + { + 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 (retainedRoots.SequenceEqual(_legacyInstallationPoolRoots, PathHelper.PathComparer)) + { + return; + } + + _legacyInstallationStorages = retainedRoots.Select(CreateStorage).ToList(); + _legacyInstallationPoolRoots = retainedRoots; + + if (retainedRoots.Count > 0) + { + _logger.LogInformation( + "Retaining {Count} legacy installation CAS pool(s) for read-only lookup: {LegacyRoots}", + retainedRoots.Count, + string.Join(", ", retainedRoots)); + } + } + + private static string NormalizeRoot(string? rootPath) + { + return string.IsNullOrWhiteSpace(rootPath) + ? string.Empty + : Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); + } + + 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/CasPoolResolver.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs index 0a278bde6..c76c016b2 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasPoolResolver.cs @@ -1,4 +1,8 @@ +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; using GenHub.Core.Models.Enums; @@ -14,6 +18,7 @@ namespace GenHub.Features.Storage.Services; public class CasPoolResolver( IOptions config, IUserSettingsService userSettingsService, + IStorageWritabilityProbe writabilityProbe, ILogger logger) : ICasPoolResolver { /// @@ -30,6 +35,7 @@ public class CasPoolResolver( ]; private readonly CasConfiguration _config = config.Value; + private readonly ConcurrentDictionary _unwritablePoolsLogged = new(PathHelper.PathComparer); /// public CasPoolType ResolvePool(ContentType contentType) @@ -67,11 +73,60 @@ public string GetPoolRootPath(ContentType contentType) return GetPoolRootPath(poolType); } + /// + public IReadOnlyList GetLegacyInstallationPoolRootPaths() + { + var configuration = userSettingsService.Get().CasConfiguration; + var roots = new List(); + + foreach (var configuredRoot in configuration.LegacyInstallationPoolRootPaths) + { + 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; + if (!string.IsNullOrWhiteSpace(currentPath) && + Directory.Exists(currentPath) && + !writabilityProbe.CanCreateStorageAt(currentPath)) + { + AddRoot(roots, currentPath); + } + + return roots; + } + /// 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; + } + + private static void AddRoot(List roots, string? root) + { + if (!string.IsNullOrWhiteSpace(root) && !roots.Contains(root, PathHelper.PathComparer)) + { + roots.Add(root); + } } /// diff --git a/GenHub/GenHub/Features/Storage/Services/CasService.cs b/GenHub/GenHub/Features/Storage/Services/CasService.cs index 09df977eb..fd372f464 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.LogDebug("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) @@ -593,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) @@ -608,6 +624,25 @@ public async Task> ExistsAsync( } } + if (!exists) + { + foreach (var fallbackStorage in poolManager.GetAllStorages()) + { + if (ReferenceEquals(fallbackStorage, storage) || + ReferenceEquals(fallbackStorage, primaryStorage)) + { + continue; + } + + if (await fallbackStorage.ObjectExistsAsync(hash, cancellationToken)) + { + logger.LogDebug("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..c5df34d79 --- /dev/null +++ b/GenHub/GenHub/Features/Storage/Services/InstallationCasPoolService.cs @@ -0,0 +1,249 @@ +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); + cancellationToken.ThrowIfCancellationRequested(); + + if (installations.Count == 0) + { + logger.LogWarning("No installations detected; the primary CAS pool will be used"); + return true; + } + + 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 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 true; + } + + var currentSettings = userSettingsService.Get(); + var configuredCurrentPath = currentSettings.CasConfiguration.InstallationPoolRootPath; + 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 && + !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 legacyPaths = SelectLegacyPaths(currentSettings, currentPath, candidatePath, effectivePath); + var settingsAlreadyMatch = + string.Equals(currentPath, effectivePath, PathHelper.PathComparison) && + currentSettings.CasConfiguration.IsInstallationPoolRootPathAutoDerived == candidateIsWritable && + currentSettings.CasConfiguration.LegacyInstallationPoolRootPaths + .Select(NormalizePath) + .SequenceEqual(legacyPaths, PathHelper.PathComparer) && + string.Equals(currentSettings.PreferredStorageInstallationId, preferredInstallation.Id, StringComparison.Ordinal) && + !currentSettings.ExplicitlySetProperties.Contains(ExplicitInstallationPoolPathKey); + if (settingsAlreadyMatch) + { + return true; + } + + cancellationToken.ThrowIfCancellationRequested(); + var saved = await userSettingsService.TryUpdateAndSaveAsync(settings => + { + settings.CasConfiguration.InstallationPoolRootPath = effectivePath; + settings.CasConfiguration.IsInstallationPoolRootPathAutoDerived = candidateIsWritable; + settings.CasConfiguration.LegacyInstallationPoolRootPaths = legacyPaths; + 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; + + 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 List SelectLegacyPaths( + UserSettings settings, + string currentPath, + string candidatePath, + string effectivePath) + { + // 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; + } + + private static void AddLegacyPath(List retainedPaths, string path, string effectivePath) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + // 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) + { + 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 e76e15add..caf379c44 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,9 +20,13 @@ 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(); + services.AddSingleton(); // CAS integration services services.AddSingleton(); @@ -35,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.LegacyInstallationPoolRootPaths = [.. userCasConfig.LegacyInstallationPoolRootPaths]; config.HashAlgorithm = userCasConfig.HashAlgorithm; config.GcGracePeriod = userCasConfig.GcGracePeriod; config.MaxCacheSizeBytes = userCasConfig.MaxCacheSizeBytes; diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ConfigurationModule.cs index 8fb2deb3d..e22fc76d3 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,12 @@ public static IServiceCollection AddConfigurationModule(this IServiceCollection bootstrapLoggerFactory.CreateLogger()); services.AddSingleton>(provider => bootstrapLoggerFactory.CreateLogger()); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.TryAddSingleton(); services.AddSingleton(); return services;