Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
76 changes: 76 additions & 0 deletions Helpers/PathPatternMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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);
}
}
3 changes: 2 additions & 1 deletion Models/ProcessPowerPlanAssociation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand Down
19 changes: 12 additions & 7 deletions Services/PersistentProcessRuleMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/
namespace ThreadPilot.Services
{
using System;
using System.IO;
using ThreadPilot.Helpers;
using ThreadPilot.Models;

public interface IPersistentProcessRuleMatcher
Expand All @@ -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) &&
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
}));
}
}
}
Loading