Skip to content

Commit 7cc7569

Browse files
Improve PowerShell command and argument escaping (#1611)
Co-authored-by: Andy Schwartzmeyer <[email protected]>
1 parent bb542fe commit 7cc7569

File tree

7 files changed

+124
-149
lines changed

7 files changed

+124
-149
lines changed

src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -156,23 +156,19 @@ private static PSCommand BuildPSCommandFromArguments(string command, IReadOnlyLi
156156
return new PSCommand().AddCommand(command);
157157
}
158158

159-
// We are forced to use a hack here so that we can reuse PowerShell's parameter binding
159+
// HACK: We use AddScript instead of AddArgument/AddParameter to reuse Powershell parameter binding logic.
160+
// We quote the command parameter so that expressions can still be used in the arguments.
160161
var sb = new StringBuilder()
161-
.Append("& ")
162-
.Append(StringEscaping.SingleQuoteAndEscape(command));
162+
.Append('&')
163+
.Append('"')
164+
.Append(command)
165+
.Append('"');
163166

164167
foreach (string arg in arguments)
165168
{
166-
sb.Append(' ');
167-
168-
if (StringEscaping.PowerShellArgumentNeedsEscaping(arg))
169-
{
170-
sb.Append(StringEscaping.SingleQuoteAndEscape(arg));
171-
}
172-
else
173-
{
174-
sb.Append(arg);
175-
}
169+
sb
170+
.Append(' ')
171+
.Append(ArgumentEscaping.Escape(arg));
176172
}
177173

178174
return new PSCommand().AddScript(sb.ToString());
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text;
5+
using System.Management.Automation.Language;
6+
7+
namespace Microsoft.PowerShell.EditorServices.Utility
8+
{
9+
internal static class ArgumentEscaping
10+
{
11+
/// <summary>
12+
/// Escape a PowerShell argument while still making it able to be evaluated in AddScript.
13+
///
14+
/// NOTE: This does not "sanitize" parameters, e.g., a pipe in one argument might affect another argument.
15+
/// This is intentional to give flexibility to specifying arguments.
16+
/// It also does not try to fix invalid PowerShell syntax, e.g., a single quote in a string literal.
17+
/// </summary>
18+
public static string Escape(string Arg)
19+
{
20+
// if argument is a scriptblock return as-is
21+
if (Arg.StartsWith("{") && Arg.EndsWith("}"))
22+
{
23+
return Arg;
24+
}
25+
26+
// If argument has a space enclose it in quotes unless it is already quoted
27+
if (Arg.Contains(" "))
28+
{
29+
if (Arg.StartsWith("\"") && Arg.EndsWith("\"") || Arg.StartsWith("'") && Arg.EndsWith("'"))
30+
{
31+
return Arg;
32+
}
33+
34+
return "\"" + Arg + "\"";
35+
}
36+
37+
return Arg;
38+
}
39+
}
40+
}

src/PowerShellEditorServices/Utility/PathUtils.cs

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -38,48 +38,22 @@ public static string NormalizePathSeparators(string path)
3838
return string.IsNullOrWhiteSpace(path) ? path : path.Replace(AlternatePathSeparator, DefaultPathSeparator);
3939
}
4040

41-
public static string WildcardEscape(string path)
42-
{
43-
return WildcardPattern.Escape(path);
44-
}
45-
4641
/// <summary>
4742
/// Return the given path with all PowerShell globbing characters escaped,
4843
/// plus optionally the whitespace.
4944
/// </summary>
5045
/// <param name="path">The path to process.</param>
5146
/// <param name="escapeSpaces">Specify True to escape spaces in the path, otherwise False.</param>
52-
/// <returns>The path with [ and ] escaped.</returns>
47+
/// <returns>The path with *, ?, [, and ] escaped, including spaces if required</returns>
5348
internal static string WildcardEscapePath(string path, bool escapeSpaces = false)
5449
{
55-
var sb = new StringBuilder();
56-
for (int i = 0; i < path.Length; i++)
57-
{
58-
char curr = path[i];
59-
switch (curr)
60-
{
61-
// Escape '[', ']', '?' and '*' with '`'
62-
case '[':
63-
case ']':
64-
case '*':
65-
case '?':
66-
case '`':
67-
sb.Append('`').Append(curr);
68-
break;
50+
var wildcardEscapedPath = WildcardPattern.Escape(path);
6951

70-
default:
71-
// Escape whitespace if required
72-
if (escapeSpaces && char.IsWhiteSpace(curr))
73-
{
74-
sb.Append('`').Append(curr);
75-
break;
76-
}
77-
sb.Append(curr);
78-
break;
79-
}
52+
if (escapeSpaces)
53+
{
54+
wildcardEscapedPath = wildcardEscapedPath.Replace(" ", "` ");
8055
}
81-
82-
return sb.ToString();
56+
return wildcardEscapedPath;
8357
}
8458
}
8559
}

src/PowerShellEditorServices/Utility/StringEscaping.cs

Lines changed: 0 additions & 37 deletions
This file was deleted.

test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@
22
// Licensed under the MIT License.
33

44
using Xunit;
5-
using System.IO;
6-
using Microsoft.PowerShell.EditorServices.Services;
75
using Microsoft.PowerShell.EditorServices.Utility;
86

97
namespace Microsoft.PowerShell.EditorServices.Test.Session
108
{
119
public class PathEscapingTests
1210
{
13-
private const string ScriptAssetPath = @"..\..\..\..\PowerShellEditorServices.Test.Shared\scriptassets";
14-
1511
[Trait("Category", "PathEscaping")]
1612
[Theory]
1713
[InlineData("DebugTest.ps1", "DebugTest.ps1")]
@@ -53,68 +49,5 @@ public void CorrectlyWildcardEscapesPaths_Spaces(string unescapedPath, string es
5349
string extensionEscapedPath = PathUtils.WildcardEscapePath(unescapedPath, escapeSpaces: true);
5450
Assert.Equal(escapedPath, extensionEscapedPath);
5551
}
56-
57-
[Trait("Category", "PathEscaping")]
58-
[Theory]
59-
[InlineData("DebugTest.ps1", "'DebugTest.ps1'")]
60-
[InlineData("../../DebugTest.ps1", "'../../DebugTest.ps1'")]
61-
[InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "'C:\\Users\\me\\Documents\\DebugTest.ps1'")]
62-
[InlineData("/home/me/Documents/weird&folder/script.ps1", "'/home/me/Documents/weird&folder/script.ps1'")]
63-
[InlineData("./path/with some/spaces", "'./path/with some/spaces'")]
64-
[InlineData("C:\\path\\with[some]brackets\\file.ps1", "'C:\\path\\with[some]brackets\\file.ps1'")]
65-
[InlineData("C:\\look\\an*\\here.ps1", "'C:\\look\\an*\\here.ps1'")]
66-
[InlineData("/Users/me/Documents/?here.ps1", "'/Users/me/Documents/?here.ps1'")]
67-
[InlineData("/Brackets [and s]paces/path.ps1", "'/Brackets [and s]paces/path.ps1'")]
68-
[InlineData("/file path/that isn't/normal/", "'/file path/that isn''t/normal/'")]
69-
[InlineData("/CJK.chars/脚本/hello.ps1", "'/CJK.chars/脚本/hello.ps1'")]
70-
[InlineData("/CJK chars/脚本/[hello].ps1", "'/CJK chars/脚本/[hello].ps1'")]
71-
[InlineData("C:\\Animal s\\утка\\quack.ps1", "'C:\\Animal s\\утка\\quack.ps1'")]
72-
[InlineData("C:\\&nimals\\утка\\qu*ck?.ps1", "'C:\\&nimals\\утка\\qu*ck?.ps1'")]
73-
public void CorrectlyQuoteEscapesPaths(string unquotedPath, string expectedQuotedPath)
74-
{
75-
string extensionQuotedPath = StringEscaping.SingleQuoteAndEscape(unquotedPath).ToString();
76-
Assert.Equal(expectedQuotedPath, extensionQuotedPath);
77-
}
78-
79-
[Trait("Category", "PathEscaping")]
80-
[Theory]
81-
[InlineData("DebugTest.ps1", "'DebugTest.ps1'")]
82-
[InlineData("../../DebugTest.ps1", "'../../DebugTest.ps1'")]
83-
[InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "'C:\\Users\\me\\Documents\\DebugTest.ps1'")]
84-
[InlineData("/home/me/Documents/weird&folder/script.ps1", "'/home/me/Documents/weird&folder/script.ps1'")]
85-
[InlineData("./path/with some/spaces", "'./path/with some/spaces'")]
86-
[InlineData("C:\\path\\with[some]brackets\\file.ps1", "'C:\\path\\with`[some`]brackets\\file.ps1'")]
87-
[InlineData("C:\\look\\an*\\here.ps1", "'C:\\look\\an`*\\here.ps1'")]
88-
[InlineData("/Users/me/Documents/?here.ps1", "'/Users/me/Documents/`?here.ps1'")]
89-
[InlineData("/Brackets [and s]paces/path.ps1", "'/Brackets `[and s`]paces/path.ps1'")]
90-
[InlineData("/file path/that isn't/normal/", "'/file path/that isn''t/normal/'")]
91-
[InlineData("/CJK.chars/脚本/hello.ps1", "'/CJK.chars/脚本/hello.ps1'")]
92-
[InlineData("/CJK chars/脚本/[hello].ps1", "'/CJK chars/脚本/`[hello`].ps1'")]
93-
[InlineData("C:\\Animal s\\утка\\quack.ps1", "'C:\\Animal s\\утка\\quack.ps1'")]
94-
[InlineData("C:\\&nimals\\утка\\qu*ck?.ps1", "'C:\\&nimals\\утка\\qu`*ck`?.ps1'")]
95-
public void CorrectlyFullyEscapesPaths(string unescapedPath, string escapedPath)
96-
{
97-
string extensionEscapedPath = StringEscaping.SingleQuoteAndEscape(PathUtils.WildcardEscapePath(unescapedPath)).ToString();
98-
Assert.Equal(escapedPath, extensionEscapedPath);
99-
}
100-
101-
[Trait("Category", "PathEscaping")]
102-
[Theory]
103-
[InlineData("NormalScript.ps1")]
104-
[InlineData("Bad&name4script.ps1")]
105-
[InlineData("[Truly] b&d Name_4_script.ps1")]
106-
public void CanDotSourcePath(string rawFileName)
107-
{
108-
string fullPath = Path.Combine(ScriptAssetPath, rawFileName);
109-
string quotedPath = StringEscaping.SingleQuoteAndEscape(fullPath).ToString();
110-
111-
var psCommand = new System.Management.Automation.PSCommand().AddScript($". {quotedPath}");
112-
113-
using (var pwsh = System.Management.Automation.PowerShell.Create())
114-
{
115-
pwsh.Commands = psCommand;
116-
pwsh.Invoke();
117-
}
118-
}
11952
}
12053
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Xunit;
5+
using Microsoft.PowerShell.EditorServices.Utility;
6+
using System.IO;
7+
using System.Management.Automation;
8+
using System.Linq;
9+
10+
namespace Microsoft.PowerShell.EditorServices.Test.Session
11+
{
12+
public class ArgumentEscapingTests
13+
{
14+
[Trait("Category", "ArgumentEscaping")]
15+
[Theory]
16+
[InlineData(" has spaces", "\" has spaces\"")]
17+
[InlineData("-Parameter", "-Parameter")]
18+
[InlineData("' single quote left alone'", "' single quote left alone'")]
19+
[InlineData("\"double quote left alone\"", "\"double quote left alone\"")]
20+
[InlineData("/path/to/fi le", "\"/path/to/fi le\"")]
21+
[InlineData("'/path/to/fi le'", "'/path/to/fi le'")]
22+
[InlineData("|pipeline", "|pipeline")]
23+
[InlineData("am&pe rsand", "\"am&pe rsand\"")]
24+
[InlineData("semicolon ;", "\"semicolon ;\"")]
25+
[InlineData(": colon", "\": colon\"")]
26+
[InlineData("$(expressions should be quoted)", "\"$(expressions should be quoted)\"")]
27+
[InlineData("{scriptBlocks should not have escaped-spaces}", "{scriptBlocks should not have escaped-spaces}")]
28+
[InlineData("-Parameter test", "\"-Parameter test\"")] //This is invalid, but should be obvious enough looking at the PSIC invocation
29+
public void CorrectlyEscapesPowerShellArguments(string Arg, string expectedArg)
30+
{
31+
string quotedArg = ArgumentEscaping.Escape(Arg);
32+
Assert.Equal(expectedArg, quotedArg);
33+
}
34+
35+
[Trait("Category", "ArgumentEscaping")]
36+
[Theory]
37+
[InlineData("/path/t o/file", "/path/t o/file")]
38+
[InlineData("/path/with/$(echo 'expression')inline", "/path/with/expressioninline")]
39+
[InlineData("/path/with/$(echo 'expression') inline", "/path/with/expression inline")]
40+
[InlineData("am&per sand", "am&per sand")]
41+
[InlineData("'inner\"\"quotes'", "inner\"\"quotes")]
42+
public void CanEvaluateArguments(string Arg, string expectedOutput)
43+
{
44+
var escapedArg = ArgumentEscaping.Escape(Arg);
45+
var psCommand = new PSCommand().AddScript($"& Write-Output {escapedArg}");
46+
using var pwsh = System.Management.Automation.PowerShell.Create();
47+
pwsh.Commands = psCommand;
48+
var scriptOutput = pwsh.Invoke<string>().First();
49+
Assert.Equal(expectedOutput, scriptOutput);
50+
}
51+
52+
[Trait("Category", "ArgumentEscaping")]
53+
[Theory]
54+
[InlineData("NormalScript.ps1")]
55+
[InlineData("Bad&name4script.ps1")]
56+
[InlineData("[Truly] b&d `Name_4_script.ps1")]
57+
public void CanDotSourcePath(string rawFileName)
58+
{
59+
var ScriptAssetPath = @"..\..\..\..\PowerShellEditorServices.Test.Shared\scriptassets";
60+
var fullPath = Path.Combine(ScriptAssetPath, rawFileName);
61+
var escapedPath = PathUtils.WildcardEscapePath(fullPath).ToString();
62+
var psCommand = new PSCommand().AddScript($"& \"{escapedPath}\"");
63+
64+
using var pwsh = System.Management.Automation.PowerShell.Create();
65+
pwsh.Commands = psCommand;
66+
pwsh.Invoke();
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)