diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs new file mode 100644 index 0000000000..67c98f274b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs @@ -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); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs index c94b08ccda..d1720bb7c4 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs @@ -2,6 +2,7 @@ // 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.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -74,6 +75,8 @@ public class QueryTests : CommandPaletteUnitTestBase [DataRow("ping bing.com", "ping.exe")] [DataRow("curl bing.com", "curl.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) { // Setup @@ -82,19 +85,24 @@ public class QueryTests : CommandPaletteUnitTestBase var pages = new ShellListPage(settings, mockHistory.Object); - pages.UpdateSearchText(string.Empty, command); - - // wait for about 1s. - await Task.Delay(1000); + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + }); var commandList = pages.GetItems(); Assert.AreEqual(1, commandList.Length); - var executeCommand = commandList.FirstOrDefault(); - Assert.IsNotNull(executeCommand); - Assert.IsNotNull(executeCommand.Icon); - Assert.IsTrue(executeCommand.Title.Contains(exeName), $"expect ${exeName} but got ${executeCommand.Title}"); + var listItem = commandList.FirstOrDefault(); + Assert.IsNotNull(listItem); + + 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] @@ -109,10 +117,11 @@ public class QueryTests : CommandPaletteUnitTestBase var pages = new ShellListPage(settings, mockHistoryService.Object); - // Test: Search for a command that exists in history - pages.UpdateSearchText(string.Empty, command); - - await Task.Delay(1000); + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + }); var commandList = pages.GetItems(); @@ -134,9 +143,11 @@ public class QueryTests : CommandPaletteUnitTestBase var pages = new ShellListPage(settings, mockHistoryService.Object); - pages.UpdateSearchText("abcdefg", string.Empty); - - await Task.Delay(1000); + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText("abcdefg", string.Empty); + }); var commandList = pages.GetItems(); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs index 29a32784ad..e509b90550 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs @@ -2,7 +2,9 @@ // 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.Linq; +using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -16,10 +18,23 @@ public class CommandPaletteUnitTestBase public IListItem[] Query(string query, IListItem[] candidates) { - IListItem[] listItems = candidates + var listItems = candidates .Where(item => MatchesFilter(query, item)) .ToArray(); 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(); + + page.ItemsChanged += (sender, args) => tcs.SetResult(null); + + modification(); + await tcs.Task; + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs index 79be63cd65..494ac49648 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs @@ -92,7 +92,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos 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 cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs new file mode 100644 index 0000000000..7f91397d1b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs @@ -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; + +/// +/// 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 +/// +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); + + /// + /// Normalizes a command line string by expanding environment variables, resolving executable paths, + /// and standardizing the format for comparison purposes. + /// + /// The command line string to normalize + /// A normalized command line string + /// + /// 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. + /// + 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(); + } + + /// + /// Expands environment variables in a string using Windows API. + /// + 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(); + } + + /// + /// Parses a command line string into arguments using CommandLineToArgvW. + /// + private static string[] ParseCommandLineToArguments(string commandLine) + { + var argv = CommandLineToArgvW(commandLine, out var argc); + + if (argv == IntPtr.Zero || argc == 0) + { + return Array.Empty(); + } + + 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); + } + } + + /// + /// Resolves the executable path from the command line arguments. + /// Handles cases where the path contains spaces and was split during parsing. + /// + 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]; + } + + /// + /// Attempts to resolve an executable path using SearchPathW. + /// + 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; + } + + /// + /// Gets the canonical (absolute, normalized) path for a file. + /// + private static string GetCanonicalPath(string path) + { + try + { + return Path.GetFullPath(path); + } + catch + { + // If canonicalization fails, return the original path + return path; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs index eed1d71e49..621a265b28 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs @@ -133,4 +133,31 @@ public class ShellListPageHelpers return li; } + + /// + /// 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 + /// + 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..]); + } + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs index b9583b7228..f513c7f058 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs @@ -152,14 +152,12 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable return; } - ShellHelpers.ParseExecutableAndArgs(expanded, out var exe, out var args); - - // Check for cancellation before file system operations - cancellationToken.ThrowIfCancellationRequested(); - // Reset the path resolution flag var couldResolvePath = false; + var exe = string.Empty; + var args = string.Empty; + var exeExists = false; var fullExePath = string.Empty; var pathIsDir = false; @@ -175,6 +173,8 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable var pathResolutionTask = Task.Run( () => { + ShellListPageHelpers.NormalizeCommandLineAndArgs(expanded, out exe, out args); + // Don't check cancellation token here - let the Task timeout handle it exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); pathIsDir = Directory.Exists(expanded);