From b34732921ab8e8ad185e1c3c6048735c39403b4e Mon Sep 17 00:00:00 2001 From: Seyedamin Mousavichashmi <76259887+SA-Mousavichashmi@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:35:26 +0330 Subject: [PATCH 1/2] feat: support wildcard pattern matching for executable paths - Add PathPatternMatcher helper supporting * and ? glob matching with path separator normalization - Update PersistentProcessRuleMatcher and ProcessPowerPlanAssociation to support pattern matching - Add unit tests covering exact matches, glob patterns, versioned AppX paths, and negative matches --- Helpers/PathPatternMatcher.cs | 57 +++++++++++++++++++ Models/ProcessPowerPlanAssociation.cs | 3 +- Services/PersistentProcessRuleMatcher.cs | 19 ++++--- .../PersistentProcessRuleMatcherTests.cs | 55 ++++++++++++++++++ 4 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 Helpers/PathPatternMatcher.cs diff --git a/Helpers/PathPatternMatcher.cs b/Helpers/PathPatternMatcher.cs new file mode 100644 index 0000000..f9f46af --- /dev/null +++ b/Helpers/PathPatternMatcher.cs @@ -0,0 +1,57 @@ +/* + * ThreadPilot - path pattern matcher with wildcard support. + */ +namespace ThreadPilot.Helpers +{ + using System; + using System.IO; + using System.Text.RegularExpressions; + + public static class PathPatternMatcher + { + private static readonly RegexOptions MatchOptions = + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + + public static bool HasWildcard(string? pattern) + { + if (string.IsnullOrEmpty(pattern)) + { + return false; + } + + return pattern.IndexOfAny(new[] { '*', '?' }) >= 0; + } + + public static bool IsPathMatch(string? pattern, string? path) + { + if (string.IsnullOrWhiteSpace(pattern) || string.IsnullOrWhiteSpace(path)) + { + return false; + } + + var trimmedPattern = pattern.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var trimmedPath = path.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + var normalizedPattern = trimmedPattern.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + var normalizedPath = trimmedPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + + if (!HasWildcard(normalizedPattern)) + { + return string.Equals(normalizedPattern, normalizedPath, StringComparison.OrdinalIgnoreCase); + } + + try + { + var regexPattern = "^" + Regex.Escape(normalizedPattern) + .Replace(@"\*", ".*") + .Replace(@"\?", ".") + "$"; + + return Regex.IsMatch(normalizedPath, regexPattern, MatchOptions); + } + catch (ArgumentException) + { + return string.Equals(normalizedPattern, normalizedPath, StringComparison.OrdinalIgnoreCase); + } + } + } +} diff --git a/Models/ProcessPowerPlanAssociation.cs b/Models/ProcessPowerPlanAssociation.cs index 24394e3..7f22da4 100644 --- a/Models/ProcessPowerPlanAssociation.cs +++ b/Models/ProcessPowerPlanAssociation.cs @@ -4,6 +4,7 @@ namespace ThreadPilot.Models using System.Collections.Generic; using System.IO; using CommunityToolkit.Mvvm.ComponentModel; + using ThreadPilot.Helpers; using ThreadPilot.Models.Core; public partial class ProcessPowerPlanAssociation : ObservableObject, IModel @@ -72,7 +73,7 @@ public bool MatchesProcess(ProcessModel process) if (this.MatchByPath && !string.IsNullOrEmpty(this.ExecutablePath)) { - return string.Equals(process.ExecutablePath, this.ExecutablePath, StringComparison.OrdinalIgnoreCase); + return PathPatternMatcher.IsPathMatch(this.ExecutablePath, process.ExecutablePath); } else { diff --git a/Services/PersistentProcessRuleMatcher.cs b/Services/PersistentProcessRuleMatcher.cs index bf69ecb..345125b 100644 --- a/Services/PersistentProcessRuleMatcher.cs +++ b/Services/PersistentProcessRuleMatcher.cs @@ -3,7 +3,9 @@ */ namespace ThreadPilot.Services { + using System; using System.IO; + using ThreadPilot.Helpers; using ThreadPilot.Models; public interface IPersistentProcessRuleMatcher @@ -28,7 +30,7 @@ public bool IsMatch(PersistentProcessRule rule, ProcessModel process) { var processPath = NormalizePath(process.ExecutablePath); return !string.IsNullOrWhiteSpace(processPath) && - string.Equals(rulePath, processPath, StringComparison.OrdinalIgnoreCase); + PathPatternMatcher.IsPathMatch(rulePath, processPath); } return !string.IsNullOrWhiteSpace(rule.ProcessName) && @@ -43,13 +45,16 @@ public bool IsMatch(PersistentProcessRule rule, ProcessModel process) } var trimmed = path.Trim(); - try + if (!PathPatternMatcher.HasWildcard(trimmed)) { - 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. + 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/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs index 40c689f..dec8445 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs @@ -76,6 +76,61 @@ public void IsMatch_WithNullPaths_DoesNotThrow() Assert.Null(exception); } + [Fact] + public void IsMatch_WithWildcardExecutablePath_MatchesPattern() + { + var rule = CreateRule(executablePath: @"*Raycast*\backend\node.exe"); + var process = CreateProcess(name: "node.exe", executablePath: @"C:\Program Files\WindowsApps\Raycast.Raycast_2.5.3.0_x64__qypenmj9wpt2a\Raycast\backend\node.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithWildcardExecutablePath_RejectsNonMatchingPath() + { + var rule = CreateRule(executablePath: @"*Raycast*\backend\node.exe"); + var process = CreateProcess(name: "node.exe", executablePath: @"C:\nvm4w\nodejs\node.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.False(result); + } + + [Fact] + public void IsMatch_WithWildcardExecutablePath_MatchesVersionGlob() + { + var rule = CreateRule(executablePath: @"C:\Program Files\WindowsApps\Raycast.Raycast_*_x64__*\Raycast\backend\node.exe"); + var process = CreateProcess(name: "node.exe", executablePath: @"C:\Program Files\WindowsApps\Raycast.Raycast_2.5.3.0_x64__qypenmj9wpt2a\Raycast\backend\node.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithQuestionMarkWildcard_MatchesSingleCharacter() + { + var rule = CreateRule(executablePath: @"C:\Games\App?\Game.exe"); + var matchProcess = CreateProcess(name: "game.exe", executablePath: @"C:\Games\App1\Game.exe"); + var noMatchProcess = CreateProcess(name: "game.exe", executablePath: @"C:\Games\App12\Game.exe"); + + Assert.True(this.matcher.IsMatch(rule, matchProcess)); + Assert.False(this.matcher.IsMatch(rule, noMatchProcess)); + } + + [Fact] + public void IsMatch_WithForwardSlashWildcard_MatchesNormalizedBackslashes() + { + var rule = CreateRule(executablePath: @"*/Raycast/*/backend/node.exe"); + var process = CreateProcess(name: "node.exe", executablePath: @"C:\Program Files\WindowsApps\Raycast.Raycast_2.5.3.0_x64__qypenmj9wpt2a\Raycast\backend\node.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + private static PersistentProcessRule CreateRule(string? processName = null, string? executablePath = null) => new() { From 9d382b704a54ea3c5f304d2b99d54067a67f749f Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 29 Sep 2026 22:04:15 +0200 Subject: [PATCH 2/2] fix: complete wildcard path matching --- Helpers/PathPatternMatcher.cs | 133 ++++++++++-------- .../PersistentProcessRuleMatcherTests.cs | 2 +- .../ProcessPowerPlanAssociationTests.cs | 33 +++++ 3 files changed, 110 insertions(+), 58 deletions(-) create mode 100644 Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationTests.cs diff --git a/Helpers/PathPatternMatcher.cs b/Helpers/PathPatternMatcher.cs index f9f46af..942049b 100644 --- a/Helpers/PathPatternMatcher.cs +++ b/Helpers/PathPatternMatcher.cs @@ -1,57 +1,76 @@ -/* - * ThreadPilot - path pattern matcher with wildcard support. - */ -namespace ThreadPilot.Helpers -{ - using System; - using System.IO; - using System.Text.RegularExpressions; - - public static class PathPatternMatcher - { - private static readonly RegexOptions MatchOptions = - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; - - public static bool HasWildcard(string? pattern) - { - if (string.IsnullOrEmpty(pattern)) - { - return false; - } - - return pattern.IndexOfAny(new[] { '*', '?' }) >= 0; - } - - public static bool IsPathMatch(string? pattern, string? path) - { - if (string.IsnullOrWhiteSpace(pattern) || string.IsnullOrWhiteSpace(path)) - { - return false; - } - - var trimmedPattern = pattern.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var trimmedPath = path.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - var normalizedPattern = trimmedPattern.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - var normalizedPath = trimmedPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - - if (!HasWildcard(normalizedPattern)) - { - return string.Equals(normalizedPattern, normalizedPath, StringComparison.OrdinalIgnoreCase); - } - - try - { - var regexPattern = "^" + Regex.Escape(normalizedPattern) - .Replace(@"\*", ".*") - .Replace(@"\?", ".") + "$"; - - return Regex.IsMatch(normalizedPath, regexPattern, MatchOptions); - } - catch (ArgumentException) - { - return string.Equals(normalizedPattern, normalizedPath, StringComparison.OrdinalIgnoreCase); - } - } - } -} +/* + * ThreadPilot - path pattern matcher with wildcard support. + */ +namespace ThreadPilot.Helpers +{ + using System; + using System.IO; + + public static class PathPatternMatcher + { + public static bool HasWildcard(string? pattern) => + !string.IsNullOrEmpty(pattern) && (pattern.Contains('*') || pattern.Contains('?')); + + public static bool IsPathMatch(string? pattern, string? path) + { + if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var normalizedPattern = Normalize(pattern); + var normalizedPath = Normalize(path); + + return HasWildcard(normalizedPattern) + ? MatchesWildcard(normalizedPattern, normalizedPath) + : string.Equals(normalizedPattern, normalizedPath, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesWildcard(string pattern, string path) + { + var patternIndex = 0; + var pathIndex = 0; + var starIndex = -1; + var retryPathIndex = -1; + + while (pathIndex < path.Length) + { + if (patternIndex < pattern.Length && + (pattern[patternIndex] == '?' || + pattern.AsSpan(patternIndex, 1).Equals( + path.AsSpan(pathIndex, 1), + StringComparison.OrdinalIgnoreCase))) + { + patternIndex++; + pathIndex++; + } + else if (patternIndex < pattern.Length && pattern[patternIndex] == '*') + { + starIndex = patternIndex++; + retryPathIndex = pathIndex; + } + else if (starIndex >= 0) + { + patternIndex = starIndex + 1; + pathIndex = ++retryPathIndex; + } + else + { + return false; + } + } + + while (patternIndex < pattern.Length && pattern[patternIndex] == '*') + { + patternIndex++; + } + + return patternIndex == pattern.Length; + } + + private static string Normalize(string path) => + path.Trim() + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs index dec8445..d894599 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs @@ -123,7 +123,7 @@ public void IsMatch_WithQuestionMarkWildcard_MatchesSingleCharacter() [Fact] public void IsMatch_WithForwardSlashWildcard_MatchesNormalizedBackslashes() { - var rule = CreateRule(executablePath: @"*/Raycast/*/backend/node.exe"); + var rule = CreateRule(executablePath: @"*/Raycast/backend/node.exe"); var process = CreateProcess(name: "node.exe", executablePath: @"C:\Program Files\WindowsApps\Raycast.Raycast_2.5.3.0_x64__qypenmj9wpt2a\Raycast\backend\node.exe"); var result = this.matcher.IsMatch(rule, process); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationTests.cs new file mode 100644 index 0000000..9a2cad9 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationTests.cs @@ -0,0 +1,33 @@ +/* + * ThreadPilot - process power plan association tests. + */ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + + public sealed class ProcessPowerPlanAssociationTests + { + [Fact] + public void MatchesProcess_WithWildcardPath_MatchesOnlyTargetPath() + { + var association = new ProcessPowerPlanAssociation + { + IsEnabled = true, + MatchByPath = true, + ExecutableName = "node", + ExecutablePath = @"*Raycast*\backend\node.exe", + }; + + Assert.True(association.MatchesProcess(new ProcessModel + { + Name = "node.exe", + ExecutablePath = @"C:\Program Files\WindowsApps\Raycast.Raycast_2.5.3.0_x64__publisher\Raycast\backend\NODE.EXE", + })); + Assert.False(association.MatchesProcess(new ProcessModel + { + Name = "node.exe", + ExecutablePath = @"C:\nvm4w\nodejs\node.exe", + })); + } + } +}