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;