diff --git a/Models/PersistentProcessRule.cs b/Models/PersistentProcessRule.cs new file mode 100644 index 0000000..a8ec47a --- /dev/null +++ b/Models/PersistentProcessRule.cs @@ -0,0 +1,64 @@ +/* + * ThreadPilot - persistent process rule models. + */ +namespace ThreadPilot.Models +{ + using System; + using System.Diagnostics; + + public sealed record PersistentProcessRule + { + public string Id { get; init; } = Guid.NewGuid().ToString("N"); + + public string Name { get; init; } = string.Empty; + + public bool IsEnabled { get; init; } + + public string? ProcessName { get; init; } + + public string? ExecutablePath { get; init; } + + public CpuSelection? CpuSelection { get; init; } + + public long? LegacyAffinityMask { get; init; } + + public ProcessPriorityClass? Priority { get; init; } + + public bool ApplyAffinityOnStart { get; init; } + + public bool ApplyPriorityOnStart { get; init; } + + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; init; } = DateTime.UtcNow; + + public string? Description { get; init; } + } + + public sealed record PersistentRuleApplyResult + { + public bool Success { get; init; } + + public string RuleId { get; init; } = string.Empty; + + public int ProcessId { get; init; } + + public string ProcessName { get; init; } = string.Empty; + + public bool AffinityApplied { get; init; } + + public bool PriorityApplied { get; init; } + + public string? ErrorCode { get; init; } + + public string UserMessage { get; init; } = string.Empty; + + public string TechnicalMessage { get; init; } = string.Empty; + + public bool IsAccessDenied { get; init; } + + public bool IsAntiCheatLikely { get; init; } + + public bool IsProcessExited { get; init; } + } +} diff --git a/Services/IPersistentProcessRuleStore.cs b/Services/IPersistentProcessRuleStore.cs new file mode 100644 index 0000000..80187a3 --- /dev/null +++ b/Services/IPersistentProcessRuleStore.cs @@ -0,0 +1,14 @@ +/* + * ThreadPilot - persistent process rule store contract. + */ +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public interface IPersistentProcessRuleStore + { + Task> LoadAsync(); + + Task SaveAsync(IReadOnlyList rules); + } +} diff --git a/Services/PersistentProcessRuleJsonStore.cs b/Services/PersistentProcessRuleJsonStore.cs new file mode 100644 index 0000000..c592ec4 --- /dev/null +++ b/Services/PersistentProcessRuleJsonStore.cs @@ -0,0 +1,69 @@ +/* + * ThreadPilot - JSON-backed persistent process rule store. + */ +namespace ThreadPilot.Services +{ + using System.IO; + using System.Text.Json; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public sealed class PersistentProcessRuleJsonStore : IPersistentProcessRuleStore + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + }; + + private readonly Func filePathProvider; + private readonly ILogger? logger; + + public PersistentProcessRuleJsonStore(ILogger? logger = null) + : this(() => StoragePaths.PersistentRulesFilePath, logger) + { + } + + internal PersistentProcessRuleJsonStore( + Func filePathProvider, + ILogger? logger = null) + { + this.filePathProvider = filePathProvider ?? throw new ArgumentNullException(nameof(filePathProvider)); + this.logger = logger; + } + + public async Task> LoadAsync() + { + var filePath = this.filePathProvider(); + if (!File.Exists(filePath)) + { + return []; + } + + try + { + var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + this.logger?.LogWarning(ex, "Could not load persistent process rules from {FilePath}", filePath); + return []; + } + } + + public async Task SaveAsync(IReadOnlyList rules) + { + ArgumentNullException.ThrowIfNull(rules); + + var filePath = this.filePathProvider(); + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(rules, JsonOptions); + await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); + } + } +} diff --git a/Services/PersistentProcessRuleMatcher.cs b/Services/PersistentProcessRuleMatcher.cs new file mode 100644 index 0000000..69955cd --- /dev/null +++ b/Services/PersistentProcessRuleMatcher.cs @@ -0,0 +1,58 @@ +/* + * ThreadPilot - persistent process rule matcher. + */ +namespace ThreadPilot.Services +{ + using System.IO; + using ThreadPilot.Models; + + public interface IPersistentProcessRuleMatcher + { + bool IsMatch(PersistentProcessRule rule, ProcessModel process); + } + + public sealed class PersistentProcessRuleMatcher : IPersistentProcessRuleMatcher + { + public bool IsMatch(PersistentProcessRule rule, ProcessModel process) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentNullException.ThrowIfNull(process); + + if (!rule.IsEnabled) + { + return false; + } + + var rulePath = NormalizePath(rule.ExecutablePath); + if (!string.IsNullOrWhiteSpace(rulePath)) + { + var processPath = NormalizePath(process.ExecutablePath); + return !string.IsNullOrWhiteSpace(processPath) && + string.Equals(rulePath, processPath, StringComparison.OrdinalIgnoreCase); + } + + return !string.IsNullOrWhiteSpace(rule.ProcessName) && + string.Equals(rule.ProcessName.Trim(), process.Name?.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var trimmed = path.Trim(); + try + { + trimmed = Path.GetFullPath(trimmed); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + // Keep matching best-effort for inaccessible or malformed process paths. + } + + return trimmed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + } +} diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs new file mode 100644 index 0000000..218b43b --- /dev/null +++ b/Services/PersistentRulesEngine.cs @@ -0,0 +1,241 @@ +/* + * ThreadPilot - persistent rules engine foundation. + */ +namespace ThreadPilot.Services +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IPersistentRulesEngine + { + Task> ApplyMatchingRulesAsync( + ProcessModel process, + CancellationToken cancellationToken = default); + } + + public sealed class PersistentRulesEngine : IPersistentRulesEngine + { + private const string MissingAffinityErrorCode = "PersistentRuleMissingAffinity"; + private const string MissingPriorityErrorCode = "PersistentRuleMissingPriority"; + private const string NoActionsErrorCode = "PersistentRuleNoActions"; + private const string PriorityApplyFailedErrorCode = "PriorityApplyFailed"; + private const string RealtimePriorityBlockedErrorCode = "RealtimePriorityBlocked"; + + private readonly IPersistentProcessRuleStore ruleStore; + private readonly IPersistentProcessRuleMatcher matcher; + private readonly IAffinityApplyService affinityApplyService; + private readonly IProcessService processService; + private readonly ILogger logger; + + public PersistentRulesEngine( + IPersistentProcessRuleStore ruleStore, + IPersistentProcessRuleMatcher matcher, + IAffinityApplyService affinityApplyService, + IProcessService processService, + ILogger logger) + { + this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); + this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); + this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ApplyMatchingRulesAsync( + ProcessModel process, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + var results = new List(); + + foreach (var rule in rules.Where(rule => this.matcher.IsMatch(rule, process))) + { + cancellationToken.ThrowIfCancellationRequested(); + results.Add(await this.ApplyRuleAsync(rule, process, cancellationToken).ConfigureAwait(false)); + } + + return results; + } + + private async Task ApplyRuleAsync( + PersistentProcessRule rule, + ProcessModel process, + CancellationToken cancellationToken) + { + var result = CreateSuccessResult(rule, process); + var success = true; + + if (!rule.ApplyAffinityOnStart && !rule.ApplyPriorityOnStart) + { + return MarkRuleConfigurationFailure( + result, + rule, + NoActionsErrorCode, + "This saved rule has no actions to apply."); + } + + if (rule.ApplyAffinityOnStart) + { + if (rule.CpuSelection == null && !rule.LegacyAffinityMask.HasValue) + { + success = false; + result = MarkRuleConfigurationFailure( + result, + rule, + MissingAffinityErrorCode, + "This saved rule has no affinity selection to apply."); + } + else + { + var affinityResult = await this.ApplyAffinityAsync(rule, process).ConfigureAwait(false); + if (affinityResult.Success) + { + result = result with { AffinityApplied = true }; + } + else + { + success = false; + result = MergeAffinityFailure(result, affinityResult); + } + } + } + + if (rule.ApplyPriorityOnStart && !result.IsProcessExited) + { + if (!rule.Priority.HasValue) + { + success = false; + result = MarkRuleConfigurationFailure( + result, + rule, + MissingPriorityErrorCode, + "This saved rule has no priority value to apply."); + } + else + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await this.processService.SetProcessPriority(process, rule.Priority.Value).ConfigureAwait(false); + result = result with { PriorityApplied = true }; + } + catch (Exception ex) + { + success = false; + result = this.MergePriorityFailure(result, ex); + } + } + } + + return result with + { + Success = success, + UserMessage = success ? "Persistent rule applied." : result.UserMessage, + TechnicalMessage = success ? $"Persistent rule '{rule.Name}' applied to process {process.Name}." : result.TechnicalMessage, + }; + } + + private Task ApplyAffinityAsync(PersistentProcessRule rule, ProcessModel process) + { + if (rule.CpuSelection != null) + { + return this.affinityApplyService.ApplyAsync(process, rule.CpuSelection); + } + + if (rule.LegacyAffinityMask.HasValue) + { + return this.affinityApplyService.ApplyAsync(process, rule.LegacyAffinityMask.Value); + } + + return Task.FromResult(AffinityApplyResult.Succeeded(0, process.ProcessorAffinity)); + } + + private static PersistentRuleApplyResult MarkRuleConfigurationFailure( + PersistentRuleApplyResult result, + PersistentProcessRule rule, + string errorCode, + string userMessage) => + result with + { + Success = false, + ErrorCode = errorCode, + UserMessage = userMessage, + TechnicalMessage = $"Persistent rule '{rule.Name}' ({rule.Id}) is incomplete: {userMessage}", + }; + + private PersistentRuleApplyResult MergePriorityFailure(PersistentRuleApplyResult result, Exception ex) + { + this.logger.LogWarning( + ex, + "Persistent rule priority apply failed for rule {RuleId} on process {ProcessName} (PID: {ProcessId})", + result.RuleId, + result.ProcessName, + result.ProcessId); + + var isProcessExited = AffinityApplyExceptionClassifier.IsProcessExited(ex); + var isAccessDenied = AffinityApplyExceptionClassifier.IsAccessDenied(ex); + var isAntiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + var isRealtimeBlocked = string.Equals( + ex.Message, + ProcessOperationUserMessages.RealtimePriorityBlocked, + StringComparison.Ordinal); + + return result with + { + ErrorCode = isRealtimeBlocked + ? RealtimePriorityBlockedErrorCode + : isProcessExited + ? AffinityApplyErrorCodes.ProcessExited + : isAntiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : isAccessDenied + ? AffinityApplyErrorCodes.AccessDenied + : PriorityApplyFailedErrorCode, + UserMessage = isRealtimeBlocked + ? ProcessOperationUserMessages.RealtimePriorityBlocked + : isProcessExited + ? ProcessOperationUserMessages.ProcessExited + : isAntiCheatLikely + ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning + : isAccessDenied + ? ProcessOperationUserMessages.AccessDenied + : "ThreadPilot could not apply the saved priority rule.", + TechnicalMessage = ex.Message, + IsAccessDenied = result.IsAccessDenied || isAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely || isAntiCheatLikely, + IsProcessExited = result.IsProcessExited || isProcessExited, + }; + } + + private static PersistentRuleApplyResult CreateSuccessResult(PersistentProcessRule rule, ProcessModel process) => + new() + { + Success = true, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = "Persistent rule applied.", + TechnicalMessage = $"Persistent rule '{rule.Name}' matched process {process.Name}.", + }; + + private static PersistentRuleApplyResult MergeAffinityFailure( + PersistentRuleApplyResult result, + AffinityApplyResult affinityResult) => + result with + { + ErrorCode = affinityResult.ErrorCode, + UserMessage = affinityResult.IsAntiCheatLikely + ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning + : affinityResult.UserMessage, + TechnicalMessage = affinityResult.TechnicalMessage, + IsAccessDenied = result.IsAccessDenied || affinityResult.IsAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely || affinityResult.IsAntiCheatLikely, + IsProcessExited = result.IsProcessExited || + affinityResult.ErrorCode == AffinityApplyErrorCodes.ProcessExited || + affinityResult.FailureReason == AffinityApplyFailureReason.ProcessTerminated, + }; + } +} diff --git a/Services/ProcessOperationUserMessages.cs b/Services/ProcessOperationUserMessages.cs index 78863c1..7e48f78 100644 --- a/Services/ProcessOperationUserMessages.cs +++ b/Services/ProcessOperationUserMessages.cs @@ -33,6 +33,12 @@ internal static class ProcessOperationUserMessages public const string PersistentLaunchTimePriorityNotice = "Persistent launch-time priority may be supported for normal processes, but it does not bypass protected process or anti-cheat restrictions."; + + public const string PersistentRulesDescription = + "Applies saved rules when a matching process starts. Some protected or anti-cheat processes may reject changes. Administrator mode can help with normal permission issues but cannot bypass protection."; + + public const string PersistentRulesProtectedProcessWarning = + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to override it."; } internal static class ProcessPriorityGuardrails diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 27cadeb..bebb7da 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -105,6 +105,9 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Services/StoragePaths.cs b/Services/StoragePaths.cs index 5721148..f8b05b4 100644 --- a/Services/StoragePaths.cs +++ b/Services/StoragePaths.cs @@ -33,6 +33,8 @@ internal static class StoragePaths public static string CoreMasksFilePath => Path.Combine(AppDataRoot, "core_masks.json"); + public static string PersistentRulesFilePath => Path.Combine(AppDataRoot, "persistent_rules.json"); + public static string PowerPlansDirectory => Path.Combine(AppDataRoot, "Powerplans"); public static void EnsureAppDataDirectories() diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs new file mode 100644 index 0000000..f11b2a4 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs @@ -0,0 +1,98 @@ +/* + * ThreadPilot - persistent process rule JSON store tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentProcessRuleJsonStoreTests + { + [Fact] + public async Task LoadAsync_WithMissingFile_ReturnsEmptyList() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + + var rules = await store.LoadAsync(); + + Assert.Empty(rules); + } + + [Fact] + public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + var rule = new PersistentProcessRule + { + Id = "rule-a", + Name = "Game", + IsEnabled = true, + ProcessName = "game.exe", + CpuSelection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }, + LegacyAffinityMask = 3, + Priority = ProcessPriorityClass.AboveNormal, + ApplyAffinityOnStart = true, + ApplyPriorityOnStart = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Description = ProcessOperationUserMessages.PersistentRulesDescription, + }; + + try + { + await store.SaveAsync([rule]); + + var loaded = await store.LoadAsync(); + + var loadedRule = Assert.Single(loaded); + Assert.Equal("rule-a", loadedRule.Id); + Assert.Equal(3, loadedRule.LegacyAffinityMask); + Assert.NotNull(loadedRule.CpuSelection); + Assert.Equal(0, loadedRule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); + } + finally + { + DeleteFile(filePath); + } + } + + [Fact] + public async Task LoadAsync_WithCorruptJson_ReturnsEmptyList() + { + var filePath = CreateTemporaryFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + await File.WriteAllTextAsync(filePath, "{ not json"); + var store = new PersistentProcessRuleJsonStore(() => filePath); + + try + { + var rules = await store.LoadAsync(); + + Assert.Empty(rules); + } + finally + { + DeleteFile(filePath); + } + } + + private static string CreateTemporaryFilePath() => + Path.Combine(Path.GetTempPath(), $"threadpilot-rules-{Guid.NewGuid():N}", "rules.json"); + + private static void DeleteFile(string filePath) + { + var directory = Path.GetDirectoryName(filePath); + if (directory != null && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs new file mode 100644 index 0000000..dd76b77 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs @@ -0,0 +1,97 @@ +/* + * ThreadPilot - persistent process rule matcher tests. + */ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentProcessRuleMatcherTests + { + private readonly PersistentProcessRuleMatcher matcher = new(); + + [Fact] + public void IsMatch_WithProcessName_MatchesCaseInsensitive() + { + var rule = CreateRule(processName: "GAME.EXE"); + var process = CreateProcess(name: "game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithExecutablePath_MatchesCaseInsensitive() + { + var rule = CreateRule(executablePath: @"C:\Games\App\Game.exe"); + var process = CreateProcess(executablePath: @"c:\games\app\game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithNameAndPath_UsesExecutablePathPriority() + { + var rule = CreateRule(processName: "game.exe", executablePath: @"C:\Games\App\Game.exe"); + var process = CreateProcess(name: "game.exe", executablePath: @"C:\Other\Game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.False(result); + } + + [Fact] + public void IsMatch_WithDisabledRule_ReturnsFalse() + { + var rule = CreateRule(processName: "game.exe") with { IsEnabled = false }; + var process = CreateProcess(name: "game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.False(result); + } + + [Fact] + public void IsMatch_WithProcessWithoutExecutablePath_CanMatchProcessName() + { + var rule = CreateRule(processName: "game.exe"); + var process = CreateProcess(name: "GAME.EXE", executablePath: string.Empty); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithNullPaths_DoesNotThrow() + { + var rule = CreateRule(processName: null, executablePath: null); + var process = CreateProcess(name: "game.exe", executablePath: null); + + var exception = Record.Exception(() => this.matcher.IsMatch(rule, process)); + + Assert.Null(exception); + } + + private static PersistentProcessRule CreateRule(string? processName = null, string? executablePath = null) => + new() + { + Id = Guid.NewGuid().ToString("N"), + Name = "Rule", + IsEnabled = true, + ProcessName = processName, + ExecutablePath = executablePath, + }; + + private static ProcessModel CreateProcess(string name = "game.exe", string? executablePath = @"C:\Games\Game.exe") => + new() + { + ProcessId = 42, + Name = name, + ExecutablePath = executablePath ?? string.Empty, + }; + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs new file mode 100644 index 0000000..99f6b78 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -0,0 +1,323 @@ +/* + * ThreadPilot - persistent rules engine tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentRulesEngineTests + { + [Fact] + public async Task ApplyMatchingRulesAsync_WithCpuSelection_AppliesCpuSelection() + { + var selection = CreateCpuSelection(); + var rule = CreateRule(cpuSelection: selection, applyAffinity: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].AffinityApplied); + affinity.Verify(s => s.ApplyAsync(process, selection), Times.Once); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAffinity() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].AffinityApplied); + affinity.Verify(s => s.ApplyAsync(process, 3), Times.Once); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithPriority_AppliesPriority() + { + var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].PriorityApplied); + processService.Verify(s => s.SetProcessPriority(process, ProcessPriorityClass.High), Times.Once); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithRealtimePriority_ReturnsControlledFailure() + { + var rule = CreateRule(priority: ProcessPriorityClass.RealTime, applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + processService + .Setup(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.RealTime)) + .ThrowsAsync(new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked)); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.PriorityApplied); + Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, result.UserMessage); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAccessDeniedAffinity_ReturnsAccessDeniedResult() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access is denied.", + isAccessDenied: true)); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAntiCheatAffinity_ReturnsSafeProtectedResult() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, + ProcessOperationUserMessages.AntiCheatProtectedLikely, + "Protected process.", + isAccessDenied: true, + isAntiCheatLikely: true)); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.True(result.IsAntiCheatLikely); + Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithProcessExitedAffinity_ReturnsProcessExitedResult() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + "Process exited.", + failureReason: AffinityApplyFailureReason.ProcessTerminated)); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.True(result.IsProcessExited); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithDisabledRule_DoesNotApply() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true) with { IsEnabled = false }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + Assert.Empty(results); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAffinityEnabledButNoAffinityPayload_ReturnsFailure() + { + var rule = CreateRule(applyAffinity: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.Equal("PersistentRuleMissingAffinity", result.ErrorCode); + Assert.Equal("This saved rule has no affinity selection to apply.", result.UserMessage); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithPriorityEnabledButNoPriorityPayload_ReturnsFailure() + { + var rule = CreateRule(applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.Equal("PersistentRuleMissingPriority", result.ErrorCode); + Assert.Equal("This saved rule has no priority value to apply.", result.UserMessage); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithNoActions_ReturnsControlledFailure() + { + var rule = CreateRule(); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.Equal("PersistentRuleNoActions", result.ErrorCode); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithMultipleMatchingRules_ReturnsResultPerRuleWithoutRetry() + { + var rules = new[] + { + CreateRule(id: "rule-a", legacyAffinityMask: 1, applyAffinity: true), + CreateRule(id: "rule-b", priority: ProcessPriorityClass.AboveNormal, applyPriority: true), + }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine(rules, affinity.Object, processService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + Assert.Equal(2, results.Count); + Assert.Contains(results, result => result.RuleId == "rule-a"); + Assert.Contains(results, result => result.RuleId == "rule-b"); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Once); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Once); + } + + private static PersistentRulesEngine CreateEngine( + IReadOnlyList rules, + IAffinityApplyService affinityApplyService, + IProcessService processService) => + new( + new FakePersistentProcessRuleStore(rules), + new PersistentProcessRuleMatcher(), + affinityApplyService, + processService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + private static Mock CreateAffinityService(AffinityApplyResult? result = null) + { + var mock = new Mock(MockBehavior.Strict); + var resolved = result ?? AffinityApplyResult.Succeeded(1, 1); + mock + .Setup(s => s.ApplyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(resolved); + mock + .Setup(s => s.ApplyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(resolved); + return mock; + } + + private static Mock CreateProcessService() + { + var mock = new Mock(MockBehavior.Strict); + mock + .Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + return mock; + } + + private static PersistentProcessRule CreateRule( + string id = "rule", + CpuSelection? cpuSelection = null, + long? legacyAffinityMask = null, + ProcessPriorityClass? priority = null, + bool applyAffinity = false, + bool applyPriority = false) => + new() + { + Id = id, + Name = id, + IsEnabled = true, + ProcessName = "game.exe", + CpuSelection = cpuSelection, + LegacyAffinityMask = legacyAffinityMask, + Priority = priority, + ApplyAffinityOnStart = applyAffinity, + ApplyPriorityOnStart = applyPriority, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + private static CpuSelection CreateCpuSelection() => + new() + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 42, + Name = "game.exe", + ExecutablePath = @"C:\Games\Game.exe", + Priority = ProcessPriorityClass.Normal, + }; + + private sealed class FakePersistentProcessRuleStore(IReadOnlyList rules) + : IPersistentProcessRuleStore + { + public Task> LoadAsync() => + Task.FromResult(rules); + + public Task SaveAsync(IReadOnlyList rules) => + Task.CompletedTask; + } + } +}