mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-15 03:07:56 +01:00
Allow Run to handle commandlines with spaces (#42016)
This better handles cases where commandlines might have embedded spaces. For something like ``` C:\Program Files\PowerShell\7\pwsh.exe -c write-host dawg ``` we'll now see that `C:\Program` isn't a file, and we'll try to look at `C:\Program Files\PowerShell\7\pwsh.exe` instead. This code is pilfered from https://github.com/microsoft/terminal/pull/12348 which fixed https://github.com/microsoft/terminal/issues/12345. Terminal has great code for normalizing a string into an executable and args, so why not just use it here. related to #41646 related to #41705 (but much more narrowly scoped) ---- I added some tests too. drive-by fix: as I was adding tests, I added a helper for "make a change to a page, and await the page's ItemsChanged". This removes a bunch of `await 1s` calls, and brings the shell page tests from like, 7s to 500ms
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
using Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||||
|
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.Ext.Shell.UnitTests;
|
||||||
|
|
||||||
|
[TestClass]
|
||||||
|
public class NormalizeCommandLineTests : CommandPaletteUnitTestBase
|
||||||
|
{
|
||||||
|
private void NormalizeTestCore(string input, string expectedExe, string expectedArgs = "")
|
||||||
|
{
|
||||||
|
ShellListPageHelpers.NormalizeCommandLineAndArgs(input, out var exe, out var args);
|
||||||
|
|
||||||
|
Assert.AreEqual(expectedExe, exe, ignoreCase: true, culture: System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
Assert.AreEqual(expectedArgs, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("ping bing.com", "c:\\Windows\\system32\\ping.exe", "bing.com")]
|
||||||
|
[DataRow("curl bing.com", "c:\\Windows\\system32\\curl.exe", "bing.com")]
|
||||||
|
[DataRow("ipconfig /all", "c:\\Windows\\system32\\ipconfig.exe", "/all")]
|
||||||
|
public void NormalizeCommandLineSimple(string input, string expectedExe, string expectedArgs = "")
|
||||||
|
{
|
||||||
|
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")]
|
||||||
|
[DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")]
|
||||||
|
public void NormalizeCommandLineSpacesInExecutablePath(string input, string expectedExe, string expectedArgs = "")
|
||||||
|
{
|
||||||
|
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("%SystemRoot%\\system32\\cmd.exe", "C:\\Windows\\System32\\cmd.exe")]
|
||||||
|
public void NormalizeWithEnvVar(string input, string expectedExe, string expectedArgs = "")
|
||||||
|
{
|
||||||
|
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("cmd --run --test", "C:\\Windows\\System32\\cmd.exe", "--run --test")]
|
||||||
|
[DataRow("cmd --run --test ", "C:\\Windows\\System32\\cmd.exe", "--run --test")]
|
||||||
|
[DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "--run --test --pass")]
|
||||||
|
public void NormalizeArgsWithSpaces(string input, string expectedExe, string expectedArgs = "")
|
||||||
|
{
|
||||||
|
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "")]
|
||||||
|
[DataRow("C:\\ThisPathDoesNotExist\\NoExecutable.exe", "C:\\ThisPathDoesNotExist\\NoExecutable.exe", "")]
|
||||||
|
public void NormalizeNonExistentExecutable(string input, string expectedExe, string expectedArgs = "")
|
||||||
|
{
|
||||||
|
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("C:\\Windows", "c:\\Windows", "")]
|
||||||
|
[DataRow("C:\\Windows foo /bar", "c:\\Windows", "foo /bar")]
|
||||||
|
public void NormalizeDirectoryAsExecutable(string input, string expectedExe, string expectedArgs = "")
|
||||||
|
{
|
||||||
|
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -74,6 +75,8 @@ public class QueryTests : CommandPaletteUnitTestBase
|
|||||||
[DataRow("ping bing.com", "ping.exe")]
|
[DataRow("ping bing.com", "ping.exe")]
|
||||||
[DataRow("curl bing.com", "curl.exe")]
|
[DataRow("curl bing.com", "curl.exe")]
|
||||||
[DataRow("ipconfig /all", "ipconfig.exe")]
|
[DataRow("ipconfig /all", "ipconfig.exe")]
|
||||||
|
[DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "MsMpEng.exe")]
|
||||||
|
[DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "MsMpEng.exe")]
|
||||||
public async Task QueryWithoutHistoryCommand(string command, string exeName)
|
public async Task QueryWithoutHistoryCommand(string command, string exeName)
|
||||||
{
|
{
|
||||||
// Setup
|
// Setup
|
||||||
@@ -82,19 +85,24 @@ public class QueryTests : CommandPaletteUnitTestBase
|
|||||||
|
|
||||||
var pages = new ShellListPage(settings, mockHistory.Object);
|
var pages = new ShellListPage(settings, mockHistory.Object);
|
||||||
|
|
||||||
pages.UpdateSearchText(string.Empty, command);
|
await UpdatePageAndWaitForItems(pages, () =>
|
||||||
|
{
|
||||||
// wait for about 1s.
|
// Test: Search for a command that exists in history
|
||||||
await Task.Delay(1000);
|
pages.UpdateSearchText(string.Empty, command);
|
||||||
|
});
|
||||||
|
|
||||||
var commandList = pages.GetItems();
|
var commandList = pages.GetItems();
|
||||||
|
|
||||||
Assert.AreEqual(1, commandList.Length);
|
Assert.AreEqual(1, commandList.Length);
|
||||||
|
|
||||||
var executeCommand = commandList.FirstOrDefault();
|
var listItem = commandList.FirstOrDefault();
|
||||||
Assert.IsNotNull(executeCommand);
|
Assert.IsNotNull(listItem);
|
||||||
Assert.IsNotNull(executeCommand.Icon);
|
|
||||||
Assert.IsTrue(executeCommand.Title.Contains(exeName), $"expect ${exeName} but got ${executeCommand.Title}");
|
var runExeListItem = listItem as RunExeItem;
|
||||||
|
Assert.IsNotNull(runExeListItem);
|
||||||
|
Assert.AreEqual(exeName, runExeListItem.Exe);
|
||||||
|
Assert.IsTrue(listItem.Title.Contains(exeName), $"expect ${exeName} but got ${listItem.Title}");
|
||||||
|
Assert.IsNotNull(listItem.Icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -109,10 +117,11 @@ public class QueryTests : CommandPaletteUnitTestBase
|
|||||||
|
|
||||||
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
||||||
|
|
||||||
// Test: Search for a command that exists in history
|
await UpdatePageAndWaitForItems(pages, () =>
|
||||||
pages.UpdateSearchText(string.Empty, command);
|
{
|
||||||
|
// Test: Search for a command that exists in history
|
||||||
await Task.Delay(1000);
|
pages.UpdateSearchText(string.Empty, command);
|
||||||
|
});
|
||||||
|
|
||||||
var commandList = pages.GetItems();
|
var commandList = pages.GetItems();
|
||||||
|
|
||||||
@@ -134,9 +143,11 @@ public class QueryTests : CommandPaletteUnitTestBase
|
|||||||
|
|
||||||
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
||||||
|
|
||||||
pages.UpdateSearchText("abcdefg", string.Empty);
|
await UpdatePageAndWaitForItems(pages, () =>
|
||||||
|
{
|
||||||
await Task.Delay(1000);
|
// Test: Search for a command that exists in history
|
||||||
|
pages.UpdateSearchText("abcdefg", string.Empty);
|
||||||
|
});
|
||||||
|
|
||||||
var commandList = pages.GetItems();
|
var commandList = pages.GetItems();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Microsoft.CommandPalette.Extensions;
|
using Microsoft.CommandPalette.Extensions;
|
||||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
@@ -16,10 +18,23 @@ public class CommandPaletteUnitTestBase
|
|||||||
|
|
||||||
public IListItem[] Query(string query, IListItem[] candidates)
|
public IListItem[] Query(string query, IListItem[] candidates)
|
||||||
{
|
{
|
||||||
IListItem[] listItems = candidates
|
var listItems = candidates
|
||||||
.Where(item => MatchesFilter(query, item))
|
.Where(item => MatchesFilter(query, item))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return listItems;
|
return listItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePageAndWaitForItems(IDynamicListPage page, Action modification)
|
||||||
|
{
|
||||||
|
// Add an event handler for the ItemsChanged event,
|
||||||
|
// Then call the modification action,
|
||||||
|
// and wait for the event to be raised.
|
||||||
|
var tcs = new TaskCompletionSource<object>();
|
||||||
|
|
||||||
|
page.ItemsChanged += (sender, args) => tcs.SetResult(null);
|
||||||
|
|
||||||
|
modification();
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
|
ShellListPageHelpers.NormalizeCommandLineAndArgs(searchText, out var exe, out var args);
|
||||||
|
|
||||||
// Check for cancellation before file system operations
|
// Check for cancellation before file system operations
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides command line normalization functionality compatible with .NET
|
||||||
|
/// Native AOT. This is a C# port of the Profile::NormalizeCommandLine function
|
||||||
|
/// from the Windows Terminal codebase.
|
||||||
|
///
|
||||||
|
/// It was ported from 7055b99ac on 2025-09-25
|
||||||
|
/// </summary>
|
||||||
|
public static class CommandLineNormalizer
|
||||||
|
{
|
||||||
|
#pragma warning disable SA1310 // Field names should not contain underscore
|
||||||
|
private const int MAX_PATH = 260;
|
||||||
|
private const uint INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF;
|
||||||
|
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
||||||
|
#pragma warning restore SA1310 // Field names should not contain underscore
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern uint ExpandEnvironmentStringsW(
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] string lpSrc,
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpDst,
|
||||||
|
uint nSize);
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern IntPtr CommandLineToArgvW(
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine,
|
||||||
|
out int pNumArgs);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern uint SearchPathW(
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] string? lpPath,
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName,
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] string? lpExtension,
|
||||||
|
uint nBufferLength,
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpBuffer,
|
||||||
|
out IntPtr lpFilePart);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern uint GetFileAttributesW(
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern IntPtr LocalFree(IntPtr hMem);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a command line string by expanding environment variables, resolving executable paths,
|
||||||
|
/// and standardizing the format for comparison purposes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="commandLine">The command line string to normalize</param>
|
||||||
|
/// <returns>A normalized command line string</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// This function performs the following operations:
|
||||||
|
/// 1. Expands environment variables (e.g., %SystemRoot% -> C:\WINDOWS)
|
||||||
|
/// 2. Parses the command line into arguments, stripping quotes
|
||||||
|
/// 3. Resolves the executable path to an absolute, canonical path
|
||||||
|
/// 4. Reconstructs the command line with null separators between arguments
|
||||||
|
///
|
||||||
|
/// Given a commandLine like:
|
||||||
|
/// * "C:\WINDOWS\System32\cmd.exe"
|
||||||
|
/// * "pwsh -WorkingDirectory ~"
|
||||||
|
/// * "C:\Program Files\PowerShell\7\pwsh.exe"
|
||||||
|
/// * "C:\Program Files\PowerShell\7\pwsh.exe -WorkingDirectory ~"
|
||||||
|
///
|
||||||
|
/// This function returns:
|
||||||
|
/// * "C:\Windows\System32\cmd.exe"
|
||||||
|
/// * "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~"
|
||||||
|
/// * "C:\Program Files\PowerShell\7\pwsh.exe"
|
||||||
|
/// * "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~"
|
||||||
|
///
|
||||||
|
/// The resulting strings are used for comparisons in profile matching.
|
||||||
|
/// </remarks>
|
||||||
|
public static string NormalizeCommandLine(string commandLine)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(commandLine))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn "%SystemRoot%\System32\cmd.exe" into "C:\WINDOWS\System32\cmd.exe".
|
||||||
|
// We do this early, as environment variables might occur anywhere in the commandLine.
|
||||||
|
var normalized = ExpandEnvironmentVariables(commandLine);
|
||||||
|
|
||||||
|
// One of the most important things this function does is to strip quotes.
|
||||||
|
// That way the commandLine "foo.exe -bar" and "\"foo.exe\" \"-bar\"" appear identical.
|
||||||
|
// We'll use CommandLineToArgvW for that as it's close to what CreateProcessW uses.
|
||||||
|
var argv = ParseCommandLineToArguments(normalized);
|
||||||
|
|
||||||
|
if (argv.Length == 0)
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The index of the first argument in argv after our executable in argv[0].
|
||||||
|
// Given {"C:\Program Files\PowerShell\7\pwsh.exe", "-WorkingDirectory", "~"} this will be 1.
|
||||||
|
var startOfArguments = 1;
|
||||||
|
|
||||||
|
// The given commandLine should start with an executable name or path.
|
||||||
|
// This loop tries to resolve relative paths, as well as executable names in %PATH%
|
||||||
|
// into absolute paths and normalizes them.
|
||||||
|
var executablePath = ResolveExecutablePath(argv, ref startOfArguments);
|
||||||
|
|
||||||
|
// We've (hopefully) finished resolving the path to the executable.
|
||||||
|
// We're now going to append all remaining arguments to the resulting string.
|
||||||
|
// If argv is {"C:\Program Files\PowerShell\7\pwsh.exe", "-WorkingDirectory", "~"},
|
||||||
|
// then we'll get "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~"
|
||||||
|
var result = new StringBuilder(executablePath);
|
||||||
|
|
||||||
|
for (var i = startOfArguments; i < argv.Length; i++)
|
||||||
|
{
|
||||||
|
result.Append('\0');
|
||||||
|
result.Append(argv[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expands environment variables in a string using Windows API.
|
||||||
|
/// </summary>
|
||||||
|
private static string ExpandEnvironmentVariables(string input)
|
||||||
|
{
|
||||||
|
const int initialBufferSize = 1024;
|
||||||
|
var buffer = new StringBuilder(initialBufferSize);
|
||||||
|
|
||||||
|
var result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity);
|
||||||
|
|
||||||
|
if (result == 0)
|
||||||
|
{
|
||||||
|
// Failed to expand, return original string
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result > buffer.Capacity)
|
||||||
|
{
|
||||||
|
// Buffer was too small, resize and try again
|
||||||
|
buffer.Capacity = (int)result;
|
||||||
|
result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity);
|
||||||
|
|
||||||
|
if (result == 0)
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a command line string into arguments using CommandLineToArgvW.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] ParseCommandLineToArguments(string commandLine)
|
||||||
|
{
|
||||||
|
var argv = CommandLineToArgvW(commandLine, out var argc);
|
||||||
|
|
||||||
|
if (argv == IntPtr.Zero || argc == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var args = new string[argc];
|
||||||
|
|
||||||
|
for (var i = 0; i < argc; i++)
|
||||||
|
{
|
||||||
|
var argPtr = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
|
||||||
|
args[i] = Marshal.PtrToStringUni(argPtr) ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
LocalFree(argv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the executable path from the command line arguments.
|
||||||
|
/// Handles cases where the path contains spaces and was split during parsing.
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveExecutablePath(string[] argv, ref int startOfArguments)
|
||||||
|
{
|
||||||
|
if (argv.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve the executable path, handling cases where spaces in paths
|
||||||
|
// might have caused the path to be split across multiple arguments
|
||||||
|
for (var pathLength = 1; pathLength <= argv.Length; pathLength++)
|
||||||
|
{
|
||||||
|
// Build potential executable path by combining arguments
|
||||||
|
var pathBuilder = new StringBuilder(argv[0]);
|
||||||
|
for (var i = 1; i < pathLength; i++)
|
||||||
|
{
|
||||||
|
pathBuilder.Append(' ');
|
||||||
|
pathBuilder.Append(argv[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidatePath = pathBuilder.ToString();
|
||||||
|
var resolvedPath = TryResolveExecutable(candidatePath);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(resolvedPath))
|
||||||
|
{
|
||||||
|
startOfArguments = pathLength;
|
||||||
|
return GetCanonicalPath(resolvedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't resolve the path, return the first argument as-is
|
||||||
|
startOfArguments = 1;
|
||||||
|
return argv[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to resolve an executable path using SearchPathW.
|
||||||
|
/// </summary>
|
||||||
|
private static string TryResolveExecutable(string executableName)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(MAX_PATH);
|
||||||
|
|
||||||
|
var result = SearchPathW(
|
||||||
|
null, // Use default search path
|
||||||
|
executableName,
|
||||||
|
".exe", // Default extension
|
||||||
|
(uint)buffer.Capacity,
|
||||||
|
buffer,
|
||||||
|
out var _); // We don't need the file part
|
||||||
|
|
||||||
|
if (result == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result > buffer.Capacity)
|
||||||
|
{
|
||||||
|
// Buffer was too small, resize and try again
|
||||||
|
buffer.Capacity = (int)result;
|
||||||
|
result = SearchPathW(null, executableName, ".exe", (uint)buffer.Capacity, buffer, out var _);
|
||||||
|
|
||||||
|
if (result == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedPath = buffer.ToString();
|
||||||
|
|
||||||
|
// Verify the resolved path exists and is not a directory
|
||||||
|
var attributes = GetFileAttributesW(resolvedPath);
|
||||||
|
|
||||||
|
return attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 ? string.Empty : resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the canonical (absolute, normalized) path for a file.
|
||||||
|
/// </summary>
|
||||||
|
private static string GetCanonicalPath(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(path);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If canonicalization fails, return the original path
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,4 +133,31 @@ public class ShellListPageHelpers
|
|||||||
|
|
||||||
return li;
|
return li;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is a version of ParseExecutableAndArgs that handles whitespace in
|
||||||
|
/// paths better. It will try to find the first matching executable in the
|
||||||
|
/// input string.
|
||||||
|
///
|
||||||
|
/// If the input is quoted, it will treat everything inside the quotes as
|
||||||
|
/// the executable. If the input is not quoted, it will try to find the
|
||||||
|
/// first segment that matches
|
||||||
|
/// </summary>
|
||||||
|
public static void NormalizeCommandLineAndArgs(string input, out string executable, out string arguments)
|
||||||
|
{
|
||||||
|
var normalized = CommandLineNormalizer.NormalizeCommandLine(input);
|
||||||
|
var segments = normalized.Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
executable = string.Empty;
|
||||||
|
arguments = string.Empty;
|
||||||
|
if (segments.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
executable = segments[0];
|
||||||
|
if (segments.Length > 1)
|
||||||
|
{
|
||||||
|
arguments = string.Join(' ', segments[1..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,14 +152,12 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ShellHelpers.ParseExecutableAndArgs(expanded, out var exe, out var args);
|
|
||||||
|
|
||||||
// Check for cancellation before file system operations
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
// Reset the path resolution flag
|
// Reset the path resolution flag
|
||||||
var couldResolvePath = false;
|
var couldResolvePath = false;
|
||||||
|
|
||||||
|
var exe = string.Empty;
|
||||||
|
var args = string.Empty;
|
||||||
|
|
||||||
var exeExists = false;
|
var exeExists = false;
|
||||||
var fullExePath = string.Empty;
|
var fullExePath = string.Empty;
|
||||||
var pathIsDir = false;
|
var pathIsDir = false;
|
||||||
@@ -175,6 +173,8 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
|||||||
var pathResolutionTask = Task.Run(
|
var pathResolutionTask = Task.Run(
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
|
ShellListPageHelpers.NormalizeCommandLineAndArgs(expanded, out exe, out args);
|
||||||
|
|
||||||
// Don't check cancellation token here - let the Task timeout handle it
|
// Don't check cancellation token here - let the Task timeout handle it
|
||||||
exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath);
|
exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath);
|
||||||
pathIsDir = Directory.Exists(expanded);
|
pathIsDir = Directory.Exists(expanded);
|
||||||
|
|||||||
Reference in New Issue
Block a user