From b737a89f32c70a38a008b84ebe260a3f6616a8c6 Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Tue, 9 Oct 2018 19:31:46 -0700 Subject: [PATCH 01/10] Fix PowerShell path escaping --- .../Server/DebugAdapter.cs | 2 +- .../Debugging/DebugService.cs | 2 +- .../Properties/AssemblyInfo.cs | 1 + .../Session/PowerShellContext.cs | 135 ++++++++++++++++-- .../Workspace/Workspace.cs | 2 +- ...est].ps1 => Debug W&ith Params [Test].ps1} | 0 .../Debugging/DebugServiceTests.cs | 2 +- .../Session/PathEscapingTests.cs | 74 ++++++++++ 8 files changed, 201 insertions(+), 17 deletions(-) rename test/PowerShellEditorServices.Test.Shared/Debugging/{Debug With Params [Test].ps1 => Debug W&ith Params [Test].ps1} (100%) create mode 100644 test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs diff --git a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs index 808f78dc0..e4b4e4704 100644 --- a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs +++ b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs @@ -259,7 +259,7 @@ protected async Task HandleLaunchRequest( // the path exists and is a directory. if (!string.IsNullOrEmpty(workingDir)) { - workingDir = PowerShellContext.UnescapePath(workingDir); + workingDir = PowerShellContext.UnescapeGlobEscapedPath(workingDir); try { if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory) diff --git a/src/PowerShellEditorServices/Debugging/DebugService.cs b/src/PowerShellEditorServices/Debugging/DebugService.cs index 1fdae53db..b656fc795 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.GlobEscapePath(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..0dd162d6b 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -796,7 +796,7 @@ 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); + script = ". " + FullyPowerShellEscapePath(script); } launchedScript = script + " " + arguments; @@ -1113,30 +1113,143 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped) { if (!isPathAlreadyEscaped) { - path = EscapePath(path, false); + path = GlobEscapePath(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 globEscapedPath = GlobEscapePath(path); + return QuoteEscapePath(globEscapedPath); + } + + /// + /// Wrap an already escaped path 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 QuoteEscapePath(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 GlobEscapePath(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 '?': + 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. + [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 +1258,10 @@ public static string EscapePath(string path, bool escapeSpaces) /// /// The path to unescape. /// The path with the ` character before [, ] and spaces removed. + [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 UnescapeGlobEscapedPath(path); } #endregion diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index edbe23d96..183130dbc 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.UnescapeGlobEscapedPath(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/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..bc90d47cd --- /dev/null +++ b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs @@ -0,0 +1,74 @@ +using System; +using Xunit; +using Microsoft.PowerShell.EditorServices; + +namespace Microsoft.PowerShell.EditorServices.Test.Session +{ + public class PathEscapingTests + { + [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")] + public void CorrectlyGlobEscapesPaths_NoSpaces(string unescapedPath, string escapedPath) + { + string extensionEscapedPath = PowerShellContext.GlobEscapePath(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")] + public void CorrectlyGlobEscapesPaths_Spaces(string unescapedPath, string escapedPath) + { + string extensionEscapedPath = PowerShellContext.GlobEscapePath(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/'")] + public void CorrectlyFullyEscapesPaths_Spaces(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")] + public void CorrectlyUnescapesPaths(string escapedPath, string expectedUnescapedPath) + { + string extensionUnescapedPath = PowerShellContext.UnescapeGlobEscapedPath(escapedPath); + Assert.Equal(expectedUnescapedPath, extensionUnescapedPath); + } + } +} From 47850784fdf5c32b027ba36b861be3241dd93fca Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Wed, 10 Oct 2018 09:17:01 -0700 Subject: [PATCH 02/10] Only use quote escaping in dot source template --- src/PowerShellEditorServices/Session/PowerShellContext.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index 0dd162d6b..d4120aa64 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -796,7 +796,7 @@ public async Task ExecuteScriptWithArgs(string script, string arguments = null, if (File.Exists(script) || File.Exists(scriptAbsPath)) { // Dot-source the launched script path - script = ". " + FullyPowerShellEscapePath(script); + script = ". " + QuoteEscapeString(script); } launchedScript = script + " " + arguments; @@ -1129,15 +1129,15 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped) internal static string FullyPowerShellEscapePath(string path) { string globEscapedPath = GlobEscapePath(path); - return QuoteEscapePath(globEscapedPath); + return QuoteEscapeString(globEscapedPath); } /// - /// Wrap an already escaped path in quotes to make it safe to use in scripts. + /// 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 QuoteEscapePath(string escapedPath) + internal static string QuoteEscapeString(string escapedPath) { var sb = new StringBuilder(escapedPath.Length + 2); // Length of string plus two quotes sb.Append('\''); From 312383230a0b3906edac27882ded790d66ad24e7 Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Wed, 10 Oct 2018 09:25:02 -0700 Subject: [PATCH 03/10] Add obsolete attributes --- src/PowerShellEditorServices/Session/PowerShellContext.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index d4120aa64..0cf1ba7b4 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. @@ -1206,6 +1207,7 @@ internal static string GlobEscapePath(string path, bool escapeSpaces = false) /// The path to process. /// Specify True to escape spaces in the path, otherwise False. /// The path with [ and ] escaped. + [Hidden, 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) { @@ -1258,6 +1260,7 @@ internal static string UnescapeGlobEscapedPath(string globEscapedPath) /// /// The path to unescape. /// The path with the ` character before [, ] and spaces removed. + [Hidden, EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("This API is not meant for public usage and should not be used.")] public static string UnescapePath(string path) { From 4dbf2f8e5c122f0fb48d3046c3b763dcc1d9b7b0 Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Wed, 10 Oct 2018 09:50:57 -0700 Subject: [PATCH 04/10] Remove hidden attribute --- src/PowerShellEditorServices/Session/PowerShellContext.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index 0cf1ba7b4..dcf7fb293 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -1207,7 +1207,7 @@ internal static string GlobEscapePath(string path, bool escapeSpaces = false) /// The path to process. /// Specify True to escape spaces in the path, otherwise False. /// The path with [ and ] escaped. - [Hidden, EditorBrowsable(EditorBrowsableState.Never)] + [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) { @@ -1260,7 +1260,7 @@ internal static string UnescapeGlobEscapedPath(string globEscapedPath) /// /// The path to unescape. /// The path with the ` character before [, ] and spaces removed. - [Hidden, EditorBrowsable(EditorBrowsableState.Never)] + [EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("This API is not meant for public usage and should not be used.")] public static string UnescapePath(string path) { From 08c8ae38c4599fe9059948c66889ea56a4d64563 Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Wed, 10 Oct 2018 10:40:20 -0700 Subject: [PATCH 05/10] Add international character escaping tests --- .../Session/PathEscapingTests.cs | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs index bc90d47cd..62c649b6c 100644 --- a/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs +++ b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs @@ -16,6 +16,10 @@ public class PathEscapingTests [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 CorrectlyGlobEscapesPaths_NoSpaces(string unescapedPath, string escapedPath) { string extensionEscapedPath = PowerShellContext.GlobEscapePath(unescapedPath); @@ -32,12 +36,37 @@ public void CorrectlyGlobEscapesPaths_NoSpaces(string unescapedPath, string esca [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 CorrectlyGlobEscapesPaths_Spaces(string unescapedPath, string escapedPath) { string extensionEscapedPath = PowerShellContext.GlobEscapePath(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'")] @@ -49,7 +78,11 @@ public void CorrectlyGlobEscapesPaths_Spaces(string unescapedPath, string escape [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/'")] - public void CorrectlyFullyEscapesPaths_Spaces(string unescapedPath, string escapedPath) + [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); @@ -65,6 +98,10 @@ public void CorrectlyFullyEscapesPaths_Spaces(string unescapedPath, string escap [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.UnescapeGlobEscapedPath(escapedPath); From e07b5cd7b3a2837a120d9134f94343273df58b76 Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Wed, 10 Oct 2018 13:24:53 -0700 Subject: [PATCH 06/10] Add dot source tests for strange paths --- .../scriptassets/Bad&name4script.ps1 | 4 ++++ .../scriptassets/NormalScript.ps1 | 0 .../[Truly] b&d Name_4_script.ps1 | 1 + .../Session/PathEscapingTests.cs | 21 +++++++++++++++++++ 4 files changed, 26 insertions(+) create mode 100644 test/PowerShellEditorServices.Test.Shared/scriptassets/Bad&name4script.ps1 create mode 100644 test/PowerShellEditorServices.Test.Shared/scriptassets/NormalScript.ps1 create mode 100644 test/PowerShellEditorServices.Test.Shared/scriptassets/[Truly] b&d Name_4_script.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/Session/PathEscapingTests.cs b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs index 62c649b6c..8adfe19ac 100644 --- a/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs +++ b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs @@ -1,11 +1,14 @@ 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")] @@ -107,5 +110,23 @@ public void CorrectlyUnescapesPaths(string escapedPath, string expectedUnescaped string extensionUnescapedPath = PowerShellContext.UnescapeGlobEscapedPath(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(); + } + } } } From 2d6a37c0f3bca7b66aac293f67813026ece338af Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Thu, 11 Oct 2018 09:32:50 -0700 Subject: [PATCH 07/10] Change use of glob to wildcard --- .../Session/PowerShellContext.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index dcf7fb293..9789d2289 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -1114,7 +1114,7 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped) { if (!isPathAlreadyEscaped) { - path = GlobEscapePath(path); + path = WildcardEscapePath(path); } runspaceHandle.Runspace.SessionStateProxy.Path.SetLocation(path); @@ -1129,7 +1129,7 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped) /// An escaped version of the path that can be embedded in PowerShell script. internal static string FullyPowerShellEscapePath(string path) { - string globEscapedPath = GlobEscapePath(path); + string globEscapedPath = WildcardEscapePath(path); return QuoteEscapeString(globEscapedPath); } @@ -1170,7 +1170,7 @@ internal static string QuoteEscapeString(string escapedPath) /// The path to process. /// Specify True to escape spaces in the path, otherwise False. /// The path with [ and ] escaped. - internal static string GlobEscapePath(string path, bool escapeSpaces = false) + internal static string WildcardEscapePath(string path, bool escapeSpaces = false) { var sb = new StringBuilder(); for (int i = 0; i < path.Length; i++) @@ -1211,10 +1211,10 @@ internal static string GlobEscapePath(string path, bool escapeSpaces = false) [Obsolete("This API is not meant for public usage and should not be used.")] public static string EscapePath(string path, bool escapeSpaces) { - return GlobEscapePath(path, escapeSpaces); + return WildcardEscapePath(path, escapeSpaces); } - internal static string UnescapeGlobEscapedPath(string globEscapedPath) + internal static string UnescapeWildcardEscapedPath(string globEscapedPath) { // Prevent relying on my implementation if we can help it if (!globEscapedPath.Contains('`')) @@ -1264,7 +1264,7 @@ internal static string UnescapeGlobEscapedPath(string globEscapedPath) [Obsolete("This API is not meant for public usage and should not be used.")] public static string UnescapePath(string path) { - return UnescapeGlobEscapedPath(path); + return UnescapeWildcardEscapedPath(path); } #endregion From ce20e65ebfcd8df43381f919ea86db3a8b6b241d Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Thu, 11 Oct 2018 10:03:07 -0700 Subject: [PATCH 08/10] Fix remaining uses of glob to wildcard --- .../Server/DebugAdapter.cs | 2 +- .../Debugging/DebugService.cs | 2 +- .../Session/PowerShellContext.cs | 20 +++++++++---------- .../Workspace/Workspace.cs | 2 +- .../Session/PathEscapingTests.cs | 10 +++++----- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs index e4b4e4704..0ebb6388a 100644 --- a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs +++ b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs @@ -259,7 +259,7 @@ protected async Task HandleLaunchRequest( // the path exists and is a directory. if (!string.IsNullOrEmpty(workingDir)) { - workingDir = PowerShellContext.UnescapeGlobEscapedPath(workingDir); + workingDir = PowerShellContext.UnescapeWildcardEscapedPath(workingDir); try { if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory) diff --git a/src/PowerShellEditorServices/Debugging/DebugService.cs b/src/PowerShellEditorServices/Debugging/DebugService.cs index b656fc795..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.GlobEscapePath(scriptPath); + PowerShellContext.WildcardEscapePath(scriptPath); if (dscBreakpoints == null || !dscBreakpoints.IsDscResourcePath(escapedScriptPath)) { diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index 9789d2289..aca8e7214 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -1129,8 +1129,8 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped) /// An escaped version of the path that can be embedded in PowerShell script. internal static string FullyPowerShellEscapePath(string path) { - string globEscapedPath = WildcardEscapePath(path); - return QuoteEscapeString(globEscapedPath); + string wildcardEscapedPath = WildcardEscapePath(path); + return QuoteEscapeString(wildcardEscapedPath); } /// @@ -1214,23 +1214,23 @@ public static string EscapePath(string path, bool escapeSpaces) return WildcardEscapePath(path, escapeSpaces); } - internal static string UnescapeWildcardEscapedPath(string globEscapedPath) + internal static string UnescapeWildcardEscapedPath(string wildcardEscapedPath) { // Prevent relying on my implementation if we can help it - if (!globEscapedPath.Contains('`')) + if (!wildcardEscapedPath.Contains('`')) { - return globEscapedPath; + return wildcardEscapedPath; } - var sb = new StringBuilder(globEscapedPath.Length); - for (int i = 0; i < globEscapedPath.Length; i++) + var sb = new StringBuilder(wildcardEscapedPath.Length); + for (int i = 0; i < wildcardEscapedPath.Length; i++) { // If we see a backtick perform a lookahead - char curr = globEscapedPath[i]; - if (curr == '`' && i + 1 < globEscapedPath.Length) + char curr = wildcardEscapedPath[i]; + if (curr == '`' && i + 1 < wildcardEscapedPath.Length) { // If the next char is an escapable one, don't add this backtick to the new string - char next = globEscapedPath[i + 1]; + char next = wildcardEscapedPath[i + 1]; switch (next) { case '[': diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index 183130dbc..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.UnescapeGlobEscapedPath(filePath); + filePath = PowerShellContext.UnescapeWildcardEscapedPath(filePath); // Get the absolute file path filePath = Path.GetFullPath(filePath); diff --git a/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs index 8adfe19ac..0ea12da51 100644 --- a/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs +++ b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs @@ -23,9 +23,9 @@ public class PathEscapingTests [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 CorrectlyGlobEscapesPaths_NoSpaces(string unescapedPath, string escapedPath) + public void CorrectlyWildcardEscapesPaths_NoSpaces(string unescapedPath, string escapedPath) { - string extensionEscapedPath = PowerShellContext.GlobEscapePath(unescapedPath); + string extensionEscapedPath = PowerShellContext.WildcardEscapePath(unescapedPath); Assert.Equal(escapedPath, extensionEscapedPath); } @@ -43,9 +43,9 @@ public void CorrectlyGlobEscapesPaths_NoSpaces(string unescapedPath, string esca [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 CorrectlyGlobEscapesPaths_Spaces(string unescapedPath, string escapedPath) + public void CorrectlyWildcardEscapesPaths_Spaces(string unescapedPath, string escapedPath) { - string extensionEscapedPath = PowerShellContext.GlobEscapePath(unescapedPath, escapeSpaces: true); + string extensionEscapedPath = PowerShellContext.WildcardEscapePath(unescapedPath, escapeSpaces: true); Assert.Equal(escapedPath, extensionEscapedPath); } @@ -107,7 +107,7 @@ public void CorrectlyFullyEscapesPaths(string unescapedPath, string escapedPath) [InlineData("C:\\&nimals\\утка\\qu`*ck`?.ps1", "C:\\&nimals\\утка\\qu*ck?.ps1")] public void CorrectlyUnescapesPaths(string escapedPath, string expectedUnescapedPath) { - string extensionUnescapedPath = PowerShellContext.UnescapeGlobEscapedPath(escapedPath); + string extensionUnescapedPath = PowerShellContext.UnescapeWildcardEscapedPath(escapedPath); Assert.Equal(expectedUnescapedPath, extensionUnescapedPath); } From 063b782a2b219c11c2fe6979f812fa4e94e53107 Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Thu, 11 Oct 2018 15:51:39 -0700 Subject: [PATCH 09/10] Correctly escape path to script --- .../Server/DebugAdapter.cs | 12 ++++++++++-- .../Session/PowerShellContext.cs | 15 +++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs index 0ebb6388a..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.UnescapeWildcardEscapedPath(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/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index aca8e7214..f1167533a 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -769,7 +769,8 @@ 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; + string reportedScript = script; + string escapedScriptPath = PowerShellContext.WildcardEscapePath(script); PSCommand command = new PSCommand(); if (arguments != null) @@ -797,21 +798,22 @@ public async Task ExecuteScriptWithArgs(string script, string arguments = null, if (File.Exists(script) || File.Exists(scriptAbsPath)) { // Dot-source the launched script path - script = ". " + QuoteEscapeString(script); + escapedScriptPath = ". " + QuoteEscapeString(escapedScriptPath); } - launchedScript = script + " " + arguments; - command.AddScript(launchedScript, false); + reportedScript += " " + arguments; + escapedScriptPath = escapedScriptPath + " " + arguments; + command.AddScript(escapedScriptPath, false); } else { - command.AddCommand(script, false); + command.AddCommand(escapedScriptPath, false); } if (writeInputToHost) { this.WriteOutput( - launchedScript + Environment.NewLine, + script + Environment.NewLine, true); } @@ -1183,6 +1185,7 @@ internal static string WildcardEscapePath(string path, bool escapeSpaces = false case ']': case '*': case '?': + case '`': sb.Append('`').Append(curr); break; From 7fab6d870ce778135c89c93a6c51cd61e9e73e8a Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Fri, 12 Oct 2018 09:27:36 -0700 Subject: [PATCH 10/10] Fix git idiocy --- .../Session/PowerShellContext.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index f1167533a..d1d70752d 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -769,8 +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 reportedScript = script; - string escapedScriptPath = PowerShellContext.WildcardEscapePath(script); + var escapedScriptPath = new StringBuilder(PowerShellContext.WildcardEscapePath(script)); PSCommand command = new PSCommand(); if (arguments != null) @@ -798,16 +797,18 @@ public async Task ExecuteScriptWithArgs(string script, string arguments = null, if (File.Exists(script) || File.Exists(scriptAbsPath)) { // Dot-source the launched script path - escapedScriptPath = ". " + QuoteEscapeString(escapedScriptPath); + string escapedFilePath = escapedScriptPath.ToString(); + escapedScriptPath = new StringBuilder(". ").Append(QuoteEscapeString(escapedFilePath)); } - reportedScript += " " + arguments; - escapedScriptPath = escapedScriptPath + " " + arguments; - command.AddScript(escapedScriptPath, false); + // Add arguments + escapedScriptPath.Append(' ').Append(arguments); + + command.AddScript(escapedScriptPath.ToString(), false); } else { - command.AddCommand(escapedScriptPath, false); + command.AddCommand(escapedScriptPath.ToString(), false); } if (writeInputToHost)