diff --git a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs index 808f78dc0..7d1e7b550 100644 --- a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs +++ b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs @@ -259,7 +259,6 @@ protected async Task HandleLaunchRequest( // the path exists and is a directory. if (!string.IsNullOrEmpty(workingDir)) { - workingDir = PowerShellContext.UnescapePath(workingDir); try { if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory) @@ -303,7 +302,16 @@ protected async Task HandleLaunchRequest( string arguments = null; if ((launchParams.Args != null) && (launchParams.Args.Length > 0)) { - arguments = string.Join(" ", launchParams.Args); + var sb = new StringBuilder(); + for (int i = 0; i < launchParams.Args.Length; i++) + { + sb.Append(PowerShellContext.QuoteEscapeString(launchParams.Args[i])); + if (i < launchParams.Args.Length - 1) + { + sb.Append(' '); + } + } + arguments = sb.ToString(); Logger.Write(LogLevel.Verbose, "Script arguments are: " + arguments); } diff --git a/src/PowerShellEditorServices/Debugging/DebugService.cs b/src/PowerShellEditorServices/Debugging/DebugService.cs index 1fdae53db..0eae58dd2 100644 --- a/src/PowerShellEditorServices/Debugging/DebugService.cs +++ b/src/PowerShellEditorServices/Debugging/DebugService.cs @@ -178,7 +178,7 @@ public async Task SetLineBreakpoints( // Fix for issue #123 - file paths that contain wildcard chars [ and ] need to // quoted and have those wildcard chars escaped. string escapedScriptPath = - PowerShellContext.EscapePath(scriptPath, escapeSpaces: false); + PowerShellContext.WildcardEscapePath(scriptPath); if (dscBreakpoints == null || !dscBreakpoints.IsDscResourcePath(escapedScriptPath)) { diff --git a/src/PowerShellEditorServices/Properties/AssemblyInfo.cs b/src/PowerShellEditorServices/Properties/AssemblyInfo.cs index 17939a129..d7cc1beb5 100644 --- a/src/PowerShellEditorServices/Properties/AssemblyInfo.cs +++ b/src/PowerShellEditorServices/Properties/AssemblyInfo.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Microsoft.PowerShell.EditorServices.Protocol")] [assembly: InternalsVisibleTo("Microsoft.PowerShell.EditorServices.Test")] [assembly: InternalsVisibleTo("Microsoft.PowerShell.EditorServices.Test.Shared")] diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index f8d6e69a3..d1d70752d 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -23,6 +23,7 @@ namespace Microsoft.PowerShell.EditorServices using System.Management.Automation.Runspaces; using Microsoft.PowerShell.EditorServices.Session.Capabilities; using System.IO; + using System.ComponentModel; /// /// Manages the lifetime and usage of a PowerShell session. @@ -768,7 +769,7 @@ await this.ExecuteCommand( /// A Task that can be awaited for completion. public async Task ExecuteScriptWithArgs(string script, string arguments = null, bool writeInputToHost = false) { - string launchedScript = script; + var escapedScriptPath = new StringBuilder(PowerShellContext.WildcardEscapePath(script)); PSCommand command = new PSCommand(); if (arguments != null) @@ -796,21 +797,24 @@ public async Task ExecuteScriptWithArgs(string script, string arguments = null, if (File.Exists(script) || File.Exists(scriptAbsPath)) { // Dot-source the launched script path - script = ". " + EscapePath(script, escapeSpaces: true); + string escapedFilePath = escapedScriptPath.ToString(); + escapedScriptPath = new StringBuilder(". ").Append(QuoteEscapeString(escapedFilePath)); } - launchedScript = script + " " + arguments; - command.AddScript(launchedScript, false); + // Add arguments + escapedScriptPath.Append(' ').Append(arguments); + + command.AddScript(escapedScriptPath.ToString(), false); } else { - command.AddCommand(script, false); + command.AddCommand(escapedScriptPath.ToString(), false); } if (writeInputToHost) { this.WriteOutput( - launchedScript + Environment.NewLine, + script + Environment.NewLine, true); } @@ -1113,30 +1117,145 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped) { if (!isPathAlreadyEscaped) { - path = EscapePath(path, false); + path = WildcardEscapePath(path); } runspaceHandle.Runspace.SessionStateProxy.Path.SetLocation(path); } } + /// + /// Fully escape a given path for use in PowerShell script. + /// Note: this will not work with PowerShell.AddParameter() + /// + /// The path to escape. + /// An escaped version of the path that can be embedded in PowerShell script. + internal static string FullyPowerShellEscapePath(string path) + { + string wildcardEscapedPath = WildcardEscapePath(path); + return QuoteEscapeString(wildcardEscapedPath); + } + + /// + /// Wrap a string in quotes to make it safe to use in scripts. + /// + /// The glob-escaped path to wrap in quotes. + /// The given path wrapped in quotes appropriately. + internal static string QuoteEscapeString(string escapedPath) + { + var sb = new StringBuilder(escapedPath.Length + 2); // Length of string plus two quotes + sb.Append('\''); + if (!escapedPath.Contains('\'')) + { + sb.Append(escapedPath); + } + else + { + foreach (char c in escapedPath) + { + if (c == '\'') + { + sb.Append("''"); + continue; + } + + sb.Append(c); + } + } + sb.Append('\''); + return sb.ToString(); + } + + /// + /// Return the given path with all PowerShell globbing characters escaped, + /// plus optionally the whitespace. + /// + /// The path to process. + /// Specify True to escape spaces in the path, otherwise False. + /// The path with [ and ] escaped. + internal static string WildcardEscapePath(string path, bool escapeSpaces = false) + { + var sb = new StringBuilder(); + for (int i = 0; i < path.Length; i++) + { + char curr = path[i]; + switch (curr) + { + // Escape '[', ']', '?' and '*' with '`' + case '[': + case ']': + case '*': + case '?': + case '`': + sb.Append('`').Append(curr); + break; + + default: + // Escape whitespace if required + if (escapeSpaces && char.IsWhiteSpace(curr)) + { + sb.Append('`').Append(curr); + break; + } + sb.Append(curr); + break; + } + } + + return sb.ToString(); + } + /// /// Returns the passed in path with the [ and ] characters escaped. Escaping spaces is optional. /// /// The path to process. /// Specify True to escape spaces in the path, otherwise False. /// The path with [ and ] escaped. + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("This API is not meant for public usage and should not be used.")] public static string EscapePath(string path, bool escapeSpaces) { - string escapedPath = Regex.Replace(path, @"(? @@ -1145,14 +1264,11 @@ public static string EscapePath(string path, bool escapeSpaces) /// /// The path to unescape. /// The path with the ` character before [, ] and spaces removed. + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("This API is not meant for public usage and should not be used.")] public static string UnescapePath(string path) { - if (!path.Contains("`")) - { - return path; - } - - return Regex.Replace(path, @"`(?=[ \[\]])", ""); + return UnescapeWildcardEscapedPath(path); } #endregion diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index edbe23d96..12bfae3a4 100644 --- a/src/PowerShellEditorServices/Workspace/Workspace.cs +++ b/src/PowerShellEditorServices/Workspace/Workspace.cs @@ -373,7 +373,7 @@ internal string ResolveFilePath(string filePath) // Clients could specify paths with escaped space, [ and ] characters which .NET APIs // will not handle. These paths will get appropriately escaped just before being passed // into the PowerShell engine. - filePath = PowerShellContext.UnescapePath(filePath); + filePath = PowerShellContext.UnescapeWildcardEscapedPath(filePath); // Get the absolute file path filePath = Path.GetFullPath(filePath); diff --git a/test/PowerShellEditorServices.Test.Shared/Debugging/Debug With Params [Test].ps1 b/test/PowerShellEditorServices.Test.Shared/Debugging/Debug W&ith Params [Test].ps1 similarity index 100% rename from test/PowerShellEditorServices.Test.Shared/Debugging/Debug With Params [Test].ps1 rename to test/PowerShellEditorServices.Test.Shared/Debugging/Debug W&ith Params [Test].ps1 diff --git a/test/PowerShellEditorServices.Test.Shared/scriptassets/Bad&name4script.ps1 b/test/PowerShellEditorServices.Test.Shared/scriptassets/Bad&name4script.ps1 new file mode 100644 index 000000000..6cc75a1a8 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/scriptassets/Bad&name4script.ps1 @@ -0,0 +1,4 @@ +function Hello +{ + "Bye" +} diff --git a/test/PowerShellEditorServices.Test.Shared/scriptassets/NormalScript.ps1 b/test/PowerShellEditorServices.Test.Shared/scriptassets/NormalScript.ps1 new file mode 100644 index 000000000..e69de29bb diff --git a/test/PowerShellEditorServices.Test.Shared/scriptassets/[Truly] b&d Name_4_script.ps1 b/test/PowerShellEditorServices.Test.Shared/scriptassets/[Truly] b&d Name_4_script.ps1 new file mode 100644 index 000000000..a7a741869 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/scriptassets/[Truly] b&d Name_4_script.ps1 @@ -0,0 +1 @@ +Write-Output "Windows won't let me put * or ? in the name of this file..." diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 65d8308b8..18ce3cf30 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -105,7 +105,7 @@ public async Task DebuggerAcceptsScriptArgs(string[] args) // it should not escape already escaped chars. ScriptFile debugWithParamsFile = this.workspace.GetFile( - @"..\..\..\..\PowerShellEditorServices.Test.Shared\Debugging\Debug` With Params `[Test].ps1"); + @"..\..\..\..\PowerShellEditorServices.Test.Shared\Debugging\Debug` W&ith Params `[Test].ps1"); await this.debugService.SetLineBreakpoints( debugWithParamsFile, diff --git a/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs new file mode 100644 index 000000000..0ea12da51 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs @@ -0,0 +1,132 @@ +using System; +using Xunit; +using Microsoft.PowerShell.EditorServices; +using System.IO; + +namespace Microsoft.PowerShell.EditorServices.Test.Session +{ + public class PathEscapingTests + { + private const string ScriptAssetPath = @"..\..\..\..\PowerShellEditorServices.Test.Shared\scriptassets"; + + [Theory] + [InlineData("DebugTest.ps1", "DebugTest.ps1")] + [InlineData("../../DebugTest.ps1", "../../DebugTest.ps1")] + [InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "C:\\Users\\me\\Documents\\DebugTest.ps1")] + [InlineData("/home/me/Documents/weird&folder/script.ps1", "/home/me/Documents/weird&folder/script.ps1")] + [InlineData("./path/with some/spaces", "./path/with some/spaces")] + [InlineData("C:\\path\\with[some]brackets\\file.ps1", "C:\\path\\with`[some`]brackets\\file.ps1")] + [InlineData("C:\\look\\an*\\here.ps1", "C:\\look\\an`*\\here.ps1")] + [InlineData("/Users/me/Documents/?here.ps1", "/Users/me/Documents/`?here.ps1")] + [InlineData("/Brackets [and s]paces/path.ps1", "/Brackets `[and s`]paces/path.ps1")] + [InlineData("/CJK.chars/脚本/hello.ps1", "/CJK.chars/脚本/hello.ps1")] + [InlineData("/CJK.chars/脚本/[hello].ps1", "/CJK.chars/脚本/`[hello`].ps1")] + [InlineData("C:\\Animals\\утка\\quack.ps1", "C:\\Animals\\утка\\quack.ps1")] + [InlineData("C:\\&nimals\\утка\\qu*ck?.ps1", "C:\\&nimals\\утка\\qu`*ck`?.ps1")] + public void CorrectlyWildcardEscapesPaths_NoSpaces(string unescapedPath, string escapedPath) + { + string extensionEscapedPath = PowerShellContext.WildcardEscapePath(unescapedPath); + Assert.Equal(escapedPath, extensionEscapedPath); + } + + [Theory] + [InlineData("DebugTest.ps1", "DebugTest.ps1")] + [InlineData("../../DebugTest.ps1", "../../DebugTest.ps1")] + [InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "C:\\Users\\me\\Documents\\DebugTest.ps1")] + [InlineData("/home/me/Documents/weird&folder/script.ps1", "/home/me/Documents/weird&folder/script.ps1")] + [InlineData("./path/with some/spaces", "./path/with` some/spaces")] + [InlineData("C:\\path\\with[some]brackets\\file.ps1", "C:\\path\\with`[some`]brackets\\file.ps1")] + [InlineData("C:\\look\\an*\\here.ps1", "C:\\look\\an`*\\here.ps1")] + [InlineData("/Users/me/Documents/?here.ps1", "/Users/me/Documents/`?here.ps1")] + [InlineData("/Brackets [and s]paces/path.ps1", "/Brackets` `[and` s`]paces/path.ps1")] + [InlineData("/CJK chars/脚本/hello.ps1", "/CJK` chars/脚本/hello.ps1")] + [InlineData("/CJK chars/脚本/[hello].ps1", "/CJK` chars/脚本/`[hello`].ps1")] + [InlineData("C:\\Animal s\\утка\\quack.ps1", "C:\\Animal` s\\утка\\quack.ps1")] + [InlineData("C:\\&nimals\\утка\\qu*ck?.ps1", "C:\\&nimals\\утка\\qu`*ck`?.ps1")] + public void CorrectlyWildcardEscapesPaths_Spaces(string unescapedPath, string escapedPath) + { + string extensionEscapedPath = PowerShellContext.WildcardEscapePath(unescapedPath, escapeSpaces: true); + Assert.Equal(escapedPath, extensionEscapedPath); + } + + [Theory] + [InlineData("DebugTest.ps1", "'DebugTest.ps1'")] + [InlineData("../../DebugTest.ps1", "'../../DebugTest.ps1'")] + [InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "'C:\\Users\\me\\Documents\\DebugTest.ps1'")] + [InlineData("/home/me/Documents/weird&folder/script.ps1", "'/home/me/Documents/weird&folder/script.ps1'")] + [InlineData("./path/with some/spaces", "'./path/with some/spaces'")] + [InlineData("C:\\path\\with[some]brackets\\file.ps1", "'C:\\path\\with[some]brackets\\file.ps1'")] + [InlineData("C:\\look\\an*\\here.ps1", "'C:\\look\\an*\\here.ps1'")] + [InlineData("/Users/me/Documents/?here.ps1", "'/Users/me/Documents/?here.ps1'")] + [InlineData("/Brackets [and s]paces/path.ps1", "'/Brackets [and s]paces/path.ps1'")] + [InlineData("/file path/that isn't/normal/", "'/file path/that isn''t/normal/'")] + [InlineData("/CJK.chars/脚本/hello.ps1", "'/CJK.chars/脚本/hello.ps1'")] + [InlineData("/CJK chars/脚本/[hello].ps1", "'/CJK chars/脚本/[hello].ps1'")] + [InlineData("C:\\Animal s\\утка\\quack.ps1", "'C:\\Animal s\\утка\\quack.ps1'")] + [InlineData("C:\\&nimals\\утка\\qu*ck?.ps1", "'C:\\&nimals\\утка\\qu*ck?.ps1'")] + public void CorrectlyQuoteEscapesPaths(string unquotedPath, string expectedQuotedPath) + { + string extensionQuotedPath = PowerShellContext.QuoteEscapeString(unquotedPath); + Assert.Equal(expectedQuotedPath, extensionQuotedPath); + } + + [Theory] + [InlineData("DebugTest.ps1", "'DebugTest.ps1'")] + [InlineData("../../DebugTest.ps1", "'../../DebugTest.ps1'")] + [InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "'C:\\Users\\me\\Documents\\DebugTest.ps1'")] + [InlineData("/home/me/Documents/weird&folder/script.ps1", "'/home/me/Documents/weird&folder/script.ps1'")] + [InlineData("./path/with some/spaces", "'./path/with some/spaces'")] + [InlineData("C:\\path\\with[some]brackets\\file.ps1", "'C:\\path\\with`[some`]brackets\\file.ps1'")] + [InlineData("C:\\look\\an*\\here.ps1", "'C:\\look\\an`*\\here.ps1'")] + [InlineData("/Users/me/Documents/?here.ps1", "'/Users/me/Documents/`?here.ps1'")] + [InlineData("/Brackets [and s]paces/path.ps1", "'/Brackets `[and s`]paces/path.ps1'")] + [InlineData("/file path/that isn't/normal/", "'/file path/that isn''t/normal/'")] + [InlineData("/CJK.chars/脚本/hello.ps1", "'/CJK.chars/脚本/hello.ps1'")] + [InlineData("/CJK chars/脚本/[hello].ps1", "'/CJK chars/脚本/`[hello`].ps1'")] + [InlineData("C:\\Animal s\\утка\\quack.ps1", "'C:\\Animal s\\утка\\quack.ps1'")] + [InlineData("C:\\&nimals\\утка\\qu*ck?.ps1", "'C:\\&nimals\\утка\\qu`*ck`?.ps1'")] + public void CorrectlyFullyEscapesPaths(string unescapedPath, string escapedPath) + { + string extensionEscapedPath = PowerShellContext.FullyPowerShellEscapePath(unescapedPath); + Assert.Equal(escapedPath, extensionEscapedPath); + } + + [Theory] + [InlineData("DebugTest.ps1", "DebugTest.ps1")] + [InlineData("../../DebugTest.ps1", "../../DebugTest.ps1")] + [InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "C:\\Users\\me\\Documents\\DebugTest.ps1")] + [InlineData("/home/me/Documents/weird&folder/script.ps1", "/home/me/Documents/weird&folder/script.ps1")] + [InlineData("./path/with` some/spaces", "./path/with some/spaces")] + [InlineData("C:\\path\\with`[some`]brackets\\file.ps1", "C:\\path\\with[some]brackets\\file.ps1")] + [InlineData("C:\\look\\an`*\\here.ps1", "C:\\look\\an*\\here.ps1")] + [InlineData("/Users/me/Documents/`?here.ps1", "/Users/me/Documents/?here.ps1")] + [InlineData("/Brackets` `[and` s`]paces/path.ps1", "/Brackets [and s]paces/path.ps1")] + [InlineData("/CJK` chars/脚本/hello.ps1", "/CJK chars/脚本/hello.ps1")] + [InlineData("/CJK` chars/脚本/`[hello`].ps1", "/CJK chars/脚本/[hello].ps1")] + [InlineData("C:\\Animal` s\\утка\\quack.ps1", "C:\\Animal s\\утка\\quack.ps1")] + [InlineData("C:\\&nimals\\утка\\qu`*ck`?.ps1", "C:\\&nimals\\утка\\qu*ck?.ps1")] + public void CorrectlyUnescapesPaths(string escapedPath, string expectedUnescapedPath) + { + string extensionUnescapedPath = PowerShellContext.UnescapeWildcardEscapedPath(escapedPath); + Assert.Equal(expectedUnescapedPath, extensionUnescapedPath); + } + + [Theory] + [InlineData("NormalScript.ps1")] + [InlineData("Bad&name4script.ps1")] + [InlineData("[Truly] b&d Name_4_script.ps1")] + public void CanDotSourcePath(string rawFileName) + { + string fullPath = Path.Combine(ScriptAssetPath, rawFileName); + string quotedPath = PowerShellContext.QuoteEscapeString(fullPath); + + var psCommand = new System.Management.Automation.PSCommand().AddScript($". {quotedPath}"); + + using (var pwsh = System.Management.Automation.PowerShell.Create()) + { + pwsh.Commands = psCommand; + pwsh.Invoke(); + } + } + } +}