Skip to content

Commit 78d96b8

Browse files
authored
Fix PowerShell wildcard escaping in debug paths (#765)
* Fix PowerShell wildcard escaping * Add quoting for debug arguments * Add obsolete attributes to APIs that should not be public * Add escaping tests * Add dot source tests for strange paths
1 parent f0cb9fa commit 78d96b8

File tree

11 files changed

+285
-23
lines changed

11 files changed

+285
-23
lines changed

src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs

+10-2
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,6 @@ protected async Task HandleLaunchRequest(
259259
// the path exists and is a directory.
260260
if (!string.IsNullOrEmpty(workingDir))
261261
{
262-
workingDir = PowerShellContext.UnescapePath(workingDir);
263262
try
264263
{
265264
if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory)
@@ -303,7 +302,16 @@ protected async Task HandleLaunchRequest(
303302
string arguments = null;
304303
if ((launchParams.Args != null) && (launchParams.Args.Length > 0))
305304
{
306-
arguments = string.Join(" ", launchParams.Args);
305+
var sb = new StringBuilder();
306+
for (int i = 0; i < launchParams.Args.Length; i++)
307+
{
308+
sb.Append(PowerShellContext.QuoteEscapeString(launchParams.Args[i]));
309+
if (i < launchParams.Args.Length - 1)
310+
{
311+
sb.Append(' ');
312+
}
313+
}
314+
arguments = sb.ToString();
307315
Logger.Write(LogLevel.Verbose, "Script arguments are: " + arguments);
308316
}
309317

src/PowerShellEditorServices/Debugging/DebugService.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ public async Task<BreakpointDetails[]> SetLineBreakpoints(
178178
// Fix for issue #123 - file paths that contain wildcard chars [ and ] need to
179179
// quoted and have those wildcard chars escaped.
180180
string escapedScriptPath =
181-
PowerShellContext.EscapePath(scriptPath, escapeSpaces: false);
181+
PowerShellContext.WildcardEscapePath(scriptPath);
182182

183183
if (dscBreakpoints == null || !dscBreakpoints.IsDscResourcePath(escapedScriptPath))
184184
{

src/PowerShellEditorServices/Properties/AssemblyInfo.cs

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
using System.Runtime.CompilerServices;
77

8+
[assembly: InternalsVisibleTo("Microsoft.PowerShell.EditorServices.Protocol")]
89
[assembly: InternalsVisibleTo("Microsoft.PowerShell.EditorServices.Test")]
910
[assembly: InternalsVisibleTo("Microsoft.PowerShell.EditorServices.Test.Shared")]
1011

src/PowerShellEditorServices/Session/PowerShellContext.cs

+134-18
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ namespace Microsoft.PowerShell.EditorServices
2323
using System.Management.Automation.Runspaces;
2424
using Microsoft.PowerShell.EditorServices.Session.Capabilities;
2525
using System.IO;
26+
using System.ComponentModel;
2627

2728
/// <summary>
2829
/// Manages the lifetime and usage of a PowerShell session.
@@ -768,7 +769,7 @@ await this.ExecuteCommand<object>(
768769
/// <returns>A Task that can be awaited for completion.</returns>
769770
public async Task ExecuteScriptWithArgs(string script, string arguments = null, bool writeInputToHost = false)
770771
{
771-
string launchedScript = script;
772+
var escapedScriptPath = new StringBuilder(PowerShellContext.WildcardEscapePath(script));
772773
PSCommand command = new PSCommand();
773774

774775
if (arguments != null)
@@ -796,21 +797,24 @@ public async Task ExecuteScriptWithArgs(string script, string arguments = null,
796797
if (File.Exists(script) || File.Exists(scriptAbsPath))
797798
{
798799
// Dot-source the launched script path
799-
script = ". " + EscapePath(script, escapeSpaces: true);
800+
string escapedFilePath = escapedScriptPath.ToString();
801+
escapedScriptPath = new StringBuilder(". ").Append(QuoteEscapeString(escapedFilePath));
800802
}
801803

802-
launchedScript = script + " " + arguments;
803-
command.AddScript(launchedScript, false);
804+
// Add arguments
805+
escapedScriptPath.Append(' ').Append(arguments);
806+
807+
command.AddScript(escapedScriptPath.ToString(), false);
804808
}
805809
else
806810
{
807-
command.AddCommand(script, false);
811+
command.AddCommand(escapedScriptPath.ToString(), false);
808812
}
809813

810814
if (writeInputToHost)
811815
{
812816
this.WriteOutput(
813-
launchedScript + Environment.NewLine,
817+
script + Environment.NewLine,
814818
true);
815819
}
816820

@@ -1113,30 +1117,145 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped)
11131117
{
11141118
if (!isPathAlreadyEscaped)
11151119
{
1116-
path = EscapePath(path, false);
1120+
path = WildcardEscapePath(path);
11171121
}
11181122

11191123
runspaceHandle.Runspace.SessionStateProxy.Path.SetLocation(path);
11201124
}
11211125
}
11221126

1127+
/// <summary>
1128+
/// Fully escape a given path for use in PowerShell script.
1129+
/// Note: this will not work with PowerShell.AddParameter()
1130+
/// </summary>
1131+
/// <param name="path">The path to escape.</param>
1132+
/// <returns>An escaped version of the path that can be embedded in PowerShell script.</returns>
1133+
internal static string FullyPowerShellEscapePath(string path)
1134+
{
1135+
string wildcardEscapedPath = WildcardEscapePath(path);
1136+
return QuoteEscapeString(wildcardEscapedPath);
1137+
}
1138+
1139+
/// <summary>
1140+
/// Wrap a string in quotes to make it safe to use in scripts.
1141+
/// </summary>
1142+
/// <param name="escapedPath">The glob-escaped path to wrap in quotes.</param>
1143+
/// <returns>The given path wrapped in quotes appropriately.</returns>
1144+
internal static string QuoteEscapeString(string escapedPath)
1145+
{
1146+
var sb = new StringBuilder(escapedPath.Length + 2); // Length of string plus two quotes
1147+
sb.Append('\'');
1148+
if (!escapedPath.Contains('\''))
1149+
{
1150+
sb.Append(escapedPath);
1151+
}
1152+
else
1153+
{
1154+
foreach (char c in escapedPath)
1155+
{
1156+
if (c == '\'')
1157+
{
1158+
sb.Append("''");
1159+
continue;
1160+
}
1161+
1162+
sb.Append(c);
1163+
}
1164+
}
1165+
sb.Append('\'');
1166+
return sb.ToString();
1167+
}
1168+
1169+
/// <summary>
1170+
/// Return the given path with all PowerShell globbing characters escaped,
1171+
/// plus optionally the whitespace.
1172+
/// </summary>
1173+
/// <param name="path">The path to process.</param>
1174+
/// <param name="escapeSpaces">Specify True to escape spaces in the path, otherwise False.</param>
1175+
/// <returns>The path with [ and ] escaped.</returns>
1176+
internal static string WildcardEscapePath(string path, bool escapeSpaces = false)
1177+
{
1178+
var sb = new StringBuilder();
1179+
for (int i = 0; i < path.Length; i++)
1180+
{
1181+
char curr = path[i];
1182+
switch (curr)
1183+
{
1184+
// Escape '[', ']', '?' and '*' with '`'
1185+
case '[':
1186+
case ']':
1187+
case '*':
1188+
case '?':
1189+
case '`':
1190+
sb.Append('`').Append(curr);
1191+
break;
1192+
1193+
default:
1194+
// Escape whitespace if required
1195+
if (escapeSpaces && char.IsWhiteSpace(curr))
1196+
{
1197+
sb.Append('`').Append(curr);
1198+
break;
1199+
}
1200+
sb.Append(curr);
1201+
break;
1202+
}
1203+
}
1204+
1205+
return sb.ToString();
1206+
}
1207+
11231208
/// <summary>
11241209
/// Returns the passed in path with the [ and ] characters escaped. Escaping spaces is optional.
11251210
/// </summary>
11261211
/// <param name="path">The path to process.</param>
11271212
/// <param name="escapeSpaces">Specify True to escape spaces in the path, otherwise False.</param>
11281213
/// <returns>The path with [ and ] escaped.</returns>
1214+
[EditorBrowsable(EditorBrowsableState.Never)]
1215+
[Obsolete("This API is not meant for public usage and should not be used.")]
11291216
public static string EscapePath(string path, bool escapeSpaces)
11301217
{
1131-
string escapedPath = Regex.Replace(path, @"(?<!`)\[", "`[");
1132-
escapedPath = Regex.Replace(escapedPath, @"(?<!`)\]", "`]");
1218+
return WildcardEscapePath(path, escapeSpaces);
1219+
}
11331220

1134-
if (escapeSpaces)
1221+
internal static string UnescapeWildcardEscapedPath(string wildcardEscapedPath)
1222+
{
1223+
// Prevent relying on my implementation if we can help it
1224+
if (!wildcardEscapedPath.Contains('`'))
11351225
{
1136-
escapedPath = Regex.Replace(escapedPath, @"(?<!`) ", "` ");
1226+
return wildcardEscapedPath;
11371227
}
11381228

1139-
return escapedPath;
1229+
var sb = new StringBuilder(wildcardEscapedPath.Length);
1230+
for (int i = 0; i < wildcardEscapedPath.Length; i++)
1231+
{
1232+
// If we see a backtick perform a lookahead
1233+
char curr = wildcardEscapedPath[i];
1234+
if (curr == '`' && i + 1 < wildcardEscapedPath.Length)
1235+
{
1236+
// If the next char is an escapable one, don't add this backtick to the new string
1237+
char next = wildcardEscapedPath[i + 1];
1238+
switch (next)
1239+
{
1240+
case '[':
1241+
case ']':
1242+
case '?':
1243+
case '*':
1244+
continue;
1245+
1246+
default:
1247+
if (char.IsWhiteSpace(next))
1248+
{
1249+
continue;
1250+
}
1251+
break;
1252+
}
1253+
}
1254+
1255+
sb.Append(curr);
1256+
}
1257+
1258+
return sb.ToString();
11401259
}
11411260

11421261
/// <summary>
@@ -1145,14 +1264,11 @@ public static string EscapePath(string path, bool escapeSpaces)
11451264
/// </summary>
11461265
/// <param name="path">The path to unescape.</param>
11471266
/// <returns>The path with the ` character before [, ] and spaces removed.</returns>
1267+
[EditorBrowsable(EditorBrowsableState.Never)]
1268+
[Obsolete("This API is not meant for public usage and should not be used.")]
11481269
public static string UnescapePath(string path)
11491270
{
1150-
if (!path.Contains("`"))
1151-
{
1152-
return path;
1153-
}
1154-
1155-
return Regex.Replace(path, @"`(?=[ \[\]])", "");
1271+
return UnescapeWildcardEscapedPath(path);
11561272
}
11571273

11581274
#endregion

src/PowerShellEditorServices/Workspace/Workspace.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ internal string ResolveFilePath(string filePath)
373373
// Clients could specify paths with escaped space, [ and ] characters which .NET APIs
374374
// will not handle. These paths will get appropriately escaped just before being passed
375375
// into the PowerShell engine.
376-
filePath = PowerShellContext.UnescapePath(filePath);
376+
filePath = PowerShellContext.UnescapeWildcardEscapedPath(filePath);
377377

378378
// Get the absolute file path
379379
filePath = Path.GetFullPath(filePath);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
function Hello
2+
{
3+
"Bye"
4+
}

test/PowerShellEditorServices.Test.Shared/scriptassets/NormalScript.ps1

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Write-Output "Windows won't let me put * or ? in the name of this file..."

test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ public async Task DebuggerAcceptsScriptArgs(string[] args)
105105
// it should not escape already escaped chars.
106106
ScriptFile debugWithParamsFile =
107107
this.workspace.GetFile(
108-
@"..\..\..\..\PowerShellEditorServices.Test.Shared\Debugging\Debug` With Params `[Test].ps1");
108+
@"..\..\..\..\PowerShellEditorServices.Test.Shared\Debugging\Debug` W&ith Params `[Test].ps1");
109109

110110
await this.debugService.SetLineBreakpoints(
111111
debugWithParamsFile,

0 commit comments

Comments
 (0)