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:
Mike Griese
2025-09-26 18:39:00 -05:00
committed by GitHub
parent 2b6c5d2cdd
commit c3398b0a01
7 changed files with 425 additions and 22 deletions

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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;
}
} }

View File

@@ -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();

View File

@@ -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;
}
}
}

View File

@@ -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..]);
}
}
} }

View File

@@ -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);