Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion GenHub/GenHub.Core/Constants/CasDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ public static class CasDefaults
/// Default garbage collection grace period in days.
/// </summary>
public const int GcGracePeriodDays = 7;
}

/// <summary>
/// Explains why destructive CAS garbage collection is currently unavailable.
/// </summary>
public const string GarbageCollectionDisabledMessage =
"CAS garbage collection is disabled until complete reachability tracking is proven safe. No CAS blobs were deleted.";
}
6 changes: 3 additions & 3 deletions GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ Task<OperationResult<BulkUntrackResult>> UntrackManifestsAsync(
CancellationToken cancellationToken = default);

/// <summary>
/// Runs garbage collection immediately.
/// Should only be called AFTER all untrack operations are complete.
/// Requests garbage collection.
/// Destructive collection is currently disabled until reachability tracking is proven complete.
/// </summary>
/// <param name="force">Whether to force collection regardless of grace period.</param>
/// <param name="lockTimeout">Optional timeout to wait for the GC lock. Defaults to 5 seconds if not specified.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result with GC statistics.</returns>
/// <returns>A disabled result with zero deletion statistics.</returns>
Task<OperationResult<GarbageCollectionStats>> RunGarbageCollectionAsync(
bool force = false,
TimeSpan? lockTimeout = null,
Expand Down
5 changes: 3 additions & 2 deletions GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ public interface ICasService
Task<OperationResult<Stream>> OpenContentStreamAsync(string hash, CancellationToken cancellationToken = default);

/// <summary>
/// Runs garbage collection to remove unreferenced content.
/// Requests garbage collection of unreferenced content.
/// Destructive collection is currently disabled until reachability tracking is proven complete.
/// </summary>
/// <param name="force">If true, ignores the grace period and deletes all unreferenced objects immediately.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the garbage collection operation.</returns>
/// <returns>A clear disabled result; no CAS blobs are deleted.</returns>
Task<CasGarbageCollectionResult> RunGarbageCollectionAsync(bool force = false, CancellationToken cancellationToken = default);

/// <summary>
Expand Down
6 changes: 6 additions & 0 deletions GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;

namespace GenHub.Core.Models.Content;

Expand Down Expand Up @@ -36,4 +37,9 @@ public record ContentRemovalResult
/// Gets the duration of the operation.
/// </summary>
public TimeSpan Duration { get; init; }

/// <summary>
/// Gets non-fatal warnings reported during removal.
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
using GenHub.Core.Constants;

namespace GenHub.Core.Models.Results.CAS;

/// <summary>
/// Result of a CAS garbage collection operation.
/// </summary>
public class CasGarbageCollectionResult : ResultBase
{
/// <summary>
/// Creates the fail-closed result returned while destructive garbage collection is disabled.
/// </summary>
/// <returns>A disabled result that reports zero deletion.</returns>
public static CasGarbageCollectionResult CreateDisabled()
{
return new CasGarbageCollectionResult(
false,
CasDefaults.GarbageCollectionDisabledMessage)
{
Disabled = true,
};
}

/// <summary>
/// Initializes a new instance of the <see cref="CasGarbageCollectionResult"/> class.
/// </summary>
Expand Down Expand Up @@ -39,6 +55,11 @@ public CasGarbageCollectionResult(bool success, string? error = null, TimeSpan e
/// <summary>Gets or sets the number of objects that were referenced and kept.</summary>
public int ObjectsReferenced { get; set; }

/// <summary>
/// Gets a value indicating whether destructive garbage collection is disabled.
/// </summary>
public bool Disabled { get; init; }

/// <summary>Gets the percentage of storage freed.</summary>
public double PercentageFreed
{
Expand All @@ -63,4 +84,4 @@ public double PercentageFreed
return (double)ObjectsDeleted / ObjectsScanned * 100;
}
}
}
}
20 changes: 20 additions & 0 deletions GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public record GarbageCollectionStats
/// </summary>
public bool InProgress { get; init; }

/// <summary>
/// Gets a value indicating whether collection was blocked because destructive GC is disabled.
/// </summary>
public bool Disabled { get; init; }

/// <summary>
/// Gets a static instance representing a skipped GC operation.
/// </summary>
Expand Down Expand Up @@ -69,4 +74,19 @@ public record GarbageCollectionStats
Skipped = true,
InProgress = true,
};

/// <summary>
/// Gets a static instance representing fail-closed disabled garbage collection.
/// </summary>
public static GarbageCollectionStats DisabledResult { get; } = new()
{
ObjectsScanned = 0,
ObjectsReferenced = 0,
ObjectsDeleted = 0,
BytesFreed = 0,
Duration = TimeSpan.Zero,
Skipped = true,
InProgress = false,
Disabled = true,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using GenHub.Core.Models.GameProfile;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Results.CAS;
using GenHub.Core.Models.Storage;
using GenHub.Core.Models.Workspace;
using GenHub.Features.AppUpdate.Interfaces;
Expand Down Expand Up @@ -338,7 +339,7 @@ public void Constructor_HandlesUserSettingsServiceException()
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task DeleteCasStorageCommand_CallsService()
public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabled()
{
// Arrange
// Setup stats to return valid data so update method works
Expand All @@ -350,6 +351,9 @@ public async Task DeleteCasStorageCommand_CallsService()
.ReturnsAsync(OperationResult<IEnumerable<WorkspaceInfo>>.CreateSuccess([]));
_mockProfileManager.Setup(x => x.GetAllProfilesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(ProfileOperationResult<IReadOnlyList<GameProfile>>.CreateSuccess([]));
_mockCasService
.Setup(x => x.RunGarbageCollectionAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CasGarbageCollectionResult.CreateDisabled());

var viewModel = new SettingsViewModel(
_mockConfigService.Object,
Expand All @@ -370,6 +374,20 @@ public async Task DeleteCasStorageCommand_CallsService()

// Assert
_mockCasService.Verify(x => x.RunGarbageCollectionAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
_mockNotificationService.Verify(
service => service.ShowInfo(
"CAS Cleanup Disabled",
CasDefaults.GarbageCollectionDisabledMessage,
It.IsAny<int?>(),
It.IsAny<bool>()),
Times.Once);
_mockNotificationService.Verify(
service => service.ShowSuccess(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<int?>(),
It.IsAny<bool>()),
Times.Never);
}

/// <summary>
Expand Down Expand Up @@ -400,4 +418,4 @@ public async Task UninstallGenHubCommand_CallsService()
// Assert
_mockUpdateManager.Verify(x => x.Uninstall(), Times.Once);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Interfaces.Storage;
using GenHub.Core.Models.Content;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Storage;
using GenHub.Features.Content.Services.Reconciliation;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;

namespace GenHub.Tests.Core.Features.Reconciliation;

/// <summary>
/// Verifies that reconciliation reports disabled garbage collection without failing
/// otherwise-successful manifest operations.
/// </summary>
public class ContentReconciliationOrchestratorGarbageCollectionTests
{
/// <summary>
/// Verifies that replacement results expose the disabled-GC warning.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
[Fact]
public async Task ExecuteContentReplacementAsync_WhenGcDisabled_ReturnsWarning()
{
var reconciliationService = new Mock<IContentReconciliationService>();
reconciliationService
.Setup(service => service.OrchestrateBulkUpdateAsync(
It.IsAny<IReadOnlyDictionary<string, string>>(),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(OperationResult<ReconciliationResult>.CreateSuccess(
ReconciliationResult.Empty));

var orchestrator = CreateOrchestrator(
reconciliationService,
CreateDisabledLifecycleManager());
var request = new ContentReplacementRequest
{
ManifestMapping = new Dictionary<string, string>
{
["1.0.publisher.mod.old"] = "2.0.publisher.mod.new",
},
RemoveOldManifests = false,
};

var result = await orchestrator.ExecuteContentReplacementAsync(request);

Assert.True(result.Success);
Assert.NotNull(result.Data);
Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings);
Assert.Equal(0, result.Data.CasObjectsCollected);
Assert.Equal(0, result.Data.BytesFreed);
}

/// <summary>
/// Verifies that removal results expose the disabled-GC warning.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
[Fact]
public async Task ExecuteContentRemovalAsync_WhenGcDisabled_ReturnsWarning()
{
var reconciliationService = new Mock<IContentReconciliationService>();
var lifecycleManager = CreateDisabledLifecycleManager();
lifecycleManager
.Setup(manager => manager.UntrackManifestsAsync(
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(OperationResult<BulkUntrackResult>.CreateSuccess(
new BulkUntrackResult(0, 0, [])));

var orchestrator = CreateOrchestrator(reconciliationService, lifecycleManager);

var result = await orchestrator.ExecuteContentRemovalAsync([]);

Assert.True(result.Success);
Assert.NotNull(result.Data);
Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings);
Assert.Equal(0, result.Data.CasObjectsCollected);
Assert.Equal(0, result.Data.BytesFreed);
}

private static Mock<ICasLifecycleManager> CreateDisabledLifecycleManager()
{
var lifecycleManager = new Mock<ICasLifecycleManager>();
lifecycleManager
.Setup(manager => manager.RunGarbageCollectionAsync(
It.IsAny<bool>(),
It.IsAny<TimeSpan?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(OperationResult<GarbageCollectionStats>.CreateFailure(
CasDefaults.GarbageCollectionDisabledMessage,
GarbageCollectionStats.DisabledResult,
TimeSpan.Zero));
return lifecycleManager;
}

private static ContentReconciliationOrchestrator CreateOrchestrator(
Mock<IContentReconciliationService> reconciliationService,
Mock<ICasLifecycleManager> lifecycleManager)
{
var auditLog = new Mock<IReconciliationAuditLog>();
auditLog
.Setup(log => log.LogOperationAsync(
It.IsAny<ReconciliationAuditEntry>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);

return new ContentReconciliationOrchestrator(
reconciliationService.Object,
Mock.Of<IContentManifestPool>(),
lifecycleManager.Object,
auditLog.Object,
NullLogger<ContentReconciliationOrchestrator>.Instance);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Storage;
using GenHub.Core.Models.Results.CAS;
using GenHub.Core.Models.Storage;
using GenHub.Features.Storage.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;

namespace GenHub.Tests.Core.Features.Storage;

/// <summary>
/// Verifies that every programmatic CAS garbage-collection layer fails closed.
/// </summary>
public class CasGarbageCollectionDisabledTests
{
/// <summary>
/// Verifies that direct service calls cannot scan or delete CAS blobs, including forced calls.
/// </summary>
/// <param name="force">Whether the caller requests forced collection.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CasService_RunGarbageCollectionAsync_IsDisabledWithoutStorageAccess(bool force)
{
var storage = new Mock<ICasStorage>(MockBehavior.Strict);
var referenceTracker = new Mock<ICasReferenceTracker>(MockBehavior.Strict);
var service = new CasService(
storage.Object,
referenceTracker.Object,
NullLogger<CasService>.Instance,
Options.Create(new CasConfiguration()),
Mock.Of<IFileHashProvider>(),
Mock.Of<IStreamHashProvider>());

var result = await service.RunGarbageCollectionAsync(force);

Assert.False(result.Success);
Assert.True(result.Disabled);
Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError);
Assert.Equal(0, result.ObjectsDeleted);
Assert.Equal(0, result.BytesFreed);
storage.VerifyNoOtherCalls();
referenceTracker.VerifyNoOtherCalls();
}

/// <summary>
/// Verifies that the lifecycle API preserves the disabled result and reports no deletion.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
[Fact]
public async Task CasLifecycleManager_RunGarbageCollectionAsync_ReportsDisabled()
{
var casService = new Mock<ICasService>();
casService
.Setup(service => service.RunGarbageCollectionAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(CasGarbageCollectionResult.CreateDisabled());

using var lifecycleManager = new CasLifecycleManager(
Mock.Of<ICasReferenceTracker>(),
casService.Object,
Mock.Of<ICasStorage>(),
Options.Create(new CasConfiguration()),
NullLogger<CasLifecycleManager>.Instance);

var result = await lifecycleManager.RunGarbageCollectionAsync(force: true);

Assert.False(result.Success);
Assert.NotNull(result.Data);
Assert.True(result.Data.Disabled);
Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError);
Assert.Equal(0, result.Data.ObjectsDeleted);
Assert.Equal(0, result.Data.BytesFreed);
}
}
Loading
Loading