Skip to content
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

Improve PowerShell command and argument escaping #1611

Merged
merged 5 commits into from
Nov 3, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 as the variable for a string builder to enable this:

text.Append(' ')
    .Append(ArgumentEscaping.Escape(arg));

Very minor.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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());
Expand Down
37 changes: 37 additions & 0 deletions src/PowerShellEditorServices/Utility/ArgumentUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Text;
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.
///
/// NOTE: This does not "santize" parameters, e.g. a pipe in one argument might affect another argument.
/// 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.
/// </summary>
public static string Escape(string Arg)
{
// if argument is a scriptblock return as-is
if (Arg.StartsWith("{") && Arg.EndsWith("}"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JustinGrote Should we strip whitespace first? In case it's { ... } or some other variation with (pointless) spaces before/after.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JustinGrote Should we strip whitespace first? In case it's { ... } or some other variation with (pointless) spaces before/after.

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;
}
}
}
38 changes: 6 additions & 32 deletions src/PowerShellEditorServices/Utility/PathUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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

Yeah it gets tricky with no real way to please everyone here. On one hand you want to enable VSCode variables like ${workspaceFolder} in which case you can't really escape manually, and on the other hand some folks may want to use expressions.

Like you said though for the command path always escape is a clear winner for sure.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SeeminglyScience the compromise for my implementation is that:

  1. Command is always wrapped in double quotes because it is used by the call operator, so ${} expressions will still work if you want to pass a file and not a command, the only file that won't work in this case is if the filename contains $(), I think that's reasonable enough to just say "dont name your files this way", should probably have an explicit exception check for this.
  2. Argument is only wrapped in quotes if you specify a space. If you really need to be literal or use workspacefolder in an arg and have it be safe, you should wrap it in quotes, a scriptblock, or $(). I think that's a good compromise

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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 ${workspaceFolder} in which case you can't really escape manually, and on the other hand some folks may want to use expressions.

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JustinGrote

looks like all the debugserver tests were commented out

I will prioritize this. I really want them up. Thanks for working on this issue!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 ${workspaceFolder} in which case you can't really escape manually, and on the other hand some folks may want to use expressions.

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.

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;
}
}
}
37 changes: 0 additions & 37 deletions src/PowerShellEditorServices/Utility/StringEscaping.cs

This file was deleted.

67 changes: 0 additions & 67 deletions test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@
// Licensed under the MIT License.

using Xunit;
using System.IO;
using Microsoft.PowerShell.EditorServices.Services;
using Microsoft.PowerShell.EditorServices.Utility;

namespace Microsoft.PowerShell.EditorServices.Test.Session
{
public class PathEscapingTests
{
private const string ScriptAssetPath = @"..\..\..\..\PowerShellEditorServices.Test.Shared\scriptassets";

[Trait("Category", "PathEscaping")]
[Theory]
[InlineData("DebugTest.ps1", "DebugTest.ps1")]
Expand Down Expand Up @@ -53,68 +49,5 @@ public void CorrectlyWildcardEscapesPaths_Spaces(string unescapedPath, string es
string extensionEscapedPath = PathUtils.WildcardEscapePath(unescapedPath, escapeSpaces: true);
Assert.Equal(escapedPath, extensionEscapedPath);
}

[Trait("Category", "PathEscaping")]
[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 = StringEscaping.SingleQuoteAndEscape(unquotedPath).ToString();
Assert.Equal(expectedQuotedPath, extensionQuotedPath);
}

[Trait("Category", "PathEscaping")]
[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 = StringEscaping.SingleQuoteAndEscape(PathUtils.WildcardEscapePath(unescapedPath)).ToString();
Assert.Equal(escapedPath, extensionEscapedPath);
}

[Trait("Category", "PathEscaping")]
[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 = StringEscaping.SingleQuoteAndEscape(fullPath).ToString();

var psCommand = new System.Management.Automation.PSCommand().AddScript($". {quotedPath}");

using (var pwsh = System.Management.Automation.PowerShell.Create())
{
pwsh.Commands = psCommand;
pwsh.Invoke();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Xunit;
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();
}
}
}