-
Notifications
You must be signed in to change notification settings - Fork 237
Improve PowerShell command and argument escaping #1611
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
2bf6b66
1168018
ad6fde2
13e86d4
d078874
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -156,23 +156,19 @@ private static PSCommand BuildPSCommandFromArguments(string command, IReadOnlyLi | |
return new PSCommand().AddCommand(command); | ||
} | ||
|
||
// We are forced to use a hack here so that we can reuse PowerShell's parameter binding | ||
// HACK: We use AddScript instead of AddArgument/AddParameter to reuse Powershell parameter binding logic. | ||
// We quote the command parameter so that expressions can still be used in the arguments. | ||
var sb = new StringBuilder() | ||
.Append("& ") | ||
.Append(StringEscaping.SingleQuoteAndEscape(command)); | ||
.Append('&') | ||
.Append('"') | ||
.Append(command) | ||
.Append('"'); | ||
|
||
foreach (string arg in arguments) | ||
{ | ||
sb.Append(' '); | ||
|
||
if (StringEscaping.PowerShellArgumentNeedsEscaping(arg)) | ||
{ | ||
sb.Append(StringEscaping.SingleQuoteAndEscape(arg)); | ||
} | ||
else | ||
{ | ||
sb.Append(arg); | ||
} | ||
sb | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: consider, sb.Append(' ').Append(ArgumentEscaping.Escape(arg)); Personally I like using text.Append(' ')
.Append(ArgumentEscaping.Escape(arg)); Very minor. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was matching style of other things in PSES, we can certainly do this if @andschwa doesn't mind. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Totally fine. I'm not super aggressive about style, I think anytime a style question is in play it should be addressed by tooling rather than back and forth on PRs. Do what you think is readable 😄 |
||
.Append(' ') | ||
.Append(ArgumentEscaping.Escape(arg)); | ||
} | ||
|
||
return new PSCommand().AddScript(sb.ToString()); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
using System.Text; | ||
andyleejordan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
using System.Management.Automation.Language; | ||
|
||
namespace Microsoft.PowerShell.EditorServices.Utility | ||
{ | ||
internal static class ArgumentEscaping | ||
{ | ||
/// <summary> | ||
/// Escape a powershell argument while still making it evaluatable in AddScript. | ||
andyleejordan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// | ||
/// NOTE: This does not "santize" parameters, e.g. a pipe in one argument might affect another argument. | ||
andyleejordan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// This is intentional to give flexibility to specifying arguments. | ||
/// It also does not try to fix invalid PowerShell syntax, e.g. a single quote in a string literal. | ||
andyleejordan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// </summary> | ||
public static string Escape(string Arg) | ||
{ | ||
// if argument is a scriptblock return as-is | ||
if (Arg.StartsWith("{") && Arg.EndsWith("}")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @JustinGrote Should we strip whitespace first? In case it's There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think this is another case of "overly helpful", if you specified your debug config and left a space at the beginning or the end before your scriptblock, I have to think you may have had an intention there. |
||
{ | ||
return Arg; | ||
} | ||
|
||
// If argument has a space enclose it in quotes unless it is already quoted | ||
if (Arg.Contains(" ")) | ||
{ | ||
if (Arg.StartsWith("\"") && Arg.EndsWith("\"") || Arg.StartsWith("'") && Arg.EndsWith("'")) | ||
{ | ||
return Arg; | ||
} | ||
|
||
return "\"" + Arg + "\""; | ||
} | ||
|
||
return Arg; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,48 +38,22 @@ public static string NormalizePathSeparators(string path) | |
return string.IsNullOrWhiteSpace(path) ? path : path.Replace(AlternatePathSeparator, DefaultPathSeparator); | ||
} | ||
|
||
public static string WildcardEscape(string path) | ||
{ | ||
return WildcardPattern.Escape(path); | ||
} | ||
|
||
/// <summary> | ||
/// Return the given path with all PowerShell globbing characters escaped, | ||
/// plus optionally the whitespace. | ||
/// </summary> | ||
/// <param name="path">The path to process.</param> | ||
/// <param name="escapeSpaces">Specify True to escape spaces in the path, otherwise False.</param> | ||
/// <returns>The path with [ and ] escaped.</returns> | ||
/// <returns>The path with *, ?, [, and ] escaped, including spaces if required</returns> | ||
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; | ||
var wildcardEscapedPath = WildcardPattern.Escape(path); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. May need to manually handle ` still due to PowerShell/PowerShell#16306. There is a good chance that this seemingly over engineered logic was here due to issue with that API. Unsure when it was added though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably do need to escape it to handle a situation where it exists in a file name at least at the command level, will probably leave it alone at an argument level There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #123 is the relevant reasoning, I don't think we need to escape every path that comes in, this should be more handled on the output side if required as part of a cmdlet argument, if it's thought the cmdlet will interpret handling characters, because in some cases that may be exactly what the caller wants. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll try to add a test for SetLineBreakpoint which is the only other place this is referenced. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah it gets tricky with no real way to please everyone here. On one hand you want to enable VSCode variables like Like you said though for the command path always escape is a clear winner for sure. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @SeeminglyScience the compromise for my implementation is that:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @SeeminglyScience looks like all the debugserver tests were commented out by @rjmholt after his commit and probably need to be rewritten. @andschwa can you come up with a way to get a debugservice instantiated to a point I can run tests against it? Been trying to figure it out and it's too much for my non-C# brain at the moment. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Wouldn't this be interpolated before it gets to the LSP server? I could be wrong, I just assumed that the Code client would interpolate their own variable expressions, and then send the full string over. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I will prioritize this. I really want them up. Thanks for working on this issue! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
They do, the problem is that you don't know that they are a path or an interpolation once they arrive to the LSP. I think this implemention is a good compromise. |
||
|
||
default: | ||
// Escape whitespace if required | ||
if (escapeSpaces && char.IsWhiteSpace(curr)) | ||
{ | ||
sb.Append('`').Append(curr); | ||
break; | ||
} | ||
sb.Append(curr); | ||
break; | ||
} | ||
if (escapeSpaces) | ||
{ | ||
wildcardEscapedPath = wildcardEscapedPath.Replace(" ", "` "); | ||
} | ||
|
||
return sb.ToString(); | ||
return wildcardEscapedPath; | ||
} | ||
} | ||
} |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
using Xunit; | ||
andyleejordan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
using Microsoft.PowerShell.EditorServices.Utility; | ||
using System.IO; | ||
using System.Management.Automation; | ||
using System.Linq; | ||
|
||
namespace Microsoft.PowerShell.EditorServices.Test.Session | ||
{ | ||
public class ArgumentEscapingTests | ||
{ | ||
[Trait("Category", "ArgumentEscaping")] | ||
[Theory] | ||
[InlineData(" has spaces", "\" has spaces\"")] | ||
[InlineData("-Parameter", "-Parameter")] | ||
[InlineData("' single quote left alone'", "' single quote left alone'")] | ||
[InlineData("\"double quote left alone\"", "\"double quote left alone\"")] | ||
[InlineData("/path/to/fi le", "\"/path/to/fi le\"")] | ||
[InlineData("'/path/to/fi le'", "'/path/to/fi le'")] | ||
[InlineData("|pipeline", "|pipeline")] | ||
[InlineData("am&pe rsand", "\"am&pe rsand\"")] | ||
[InlineData("semicolon ;", "\"semicolon ;\"")] | ||
[InlineData(": colon", "\": colon\"")] | ||
[InlineData("$(expressions should be quoted)", "\"$(expressions should be quoted)\"")] | ||
[InlineData("{scriptBlocks should not have escaped-spaces}", "{scriptBlocks should not have escaped-spaces}")] | ||
[InlineData("-Parameter test", "\"-Parameter test\"")] //This is invalid, but should be obvious enough looking at the PSIC invocation | ||
public void CorrectlyEscapesPowerShellArguments(string Arg, string expectedArg) | ||
{ | ||
string quotedArg = ArgumentEscaping.Escape(Arg); | ||
Assert.Equal(expectedArg, quotedArg); | ||
} | ||
|
||
[Trait("Category", "ArgumentEscaping")] | ||
[Theory] | ||
[InlineData("/path/t o/file", "/path/t o/file")] | ||
[InlineData("/path/with/$(echo 'expression')inline", "/path/with/expressioninline")] | ||
[InlineData("/path/with/$(echo 'expression') inline", "/path/with/expression inline")] | ||
[InlineData("am&per sand", "am&per sand")] | ||
[InlineData("'inner\"\"quotes'", "inner\"\"quotes")] | ||
public void CanEvaluateArguments(string Arg, string expectedOutput) | ||
{ | ||
var escapedArg = ArgumentEscaping.Escape(Arg); | ||
var psCommand = new PSCommand().AddScript($"& Write-Output {escapedArg}"); | ||
using var pwsh = System.Management.Automation.PowerShell.Create(); | ||
pwsh.Commands = psCommand; | ||
var scriptOutput = pwsh.Invoke<string>().First(); | ||
Assert.Equal(expectedOutput, scriptOutput); | ||
} | ||
|
||
[Trait("Category", "ArgumentEscaping")] | ||
[Theory] | ||
[InlineData("NormalScript.ps1")] | ||
[InlineData("Bad&name4script.ps1")] | ||
[InlineData("[Truly] b&d `Name_4_script.ps1")] | ||
public void CanDotSourcePath(string rawFileName) | ||
{ | ||
var ScriptAssetPath = @"..\..\..\..\PowerShellEditorServices.Test.Shared\scriptassets"; | ||
var fullPath = Path.Combine(ScriptAssetPath, rawFileName); | ||
var escapedPath = PathUtils.WildcardEscapePath(fullPath).ToString(); | ||
var psCommand = new PSCommand().AddScript($"& \"{escapedPath}\""); | ||
|
||
using var pwsh = System.Management.Automation.PowerShell.Create(); | ||
pwsh.Commands = psCommand; | ||
pwsh.Invoke(); | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.