Skip to content

Commit b737a89

Browse files
author
Robert Holt
committed
Fix PowerShell path escaping
1 parent 80920c6 commit b737a89

File tree

8 files changed

+201
-17
lines changed

8 files changed

+201
-17
lines changed

src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ protected async Task HandleLaunchRequest(
259259
// the path exists and is a directory.
260260
if (!string.IsNullOrEmpty(workingDir))
261261
{
262-
workingDir = PowerShellContext.UnescapePath(workingDir);
262+
workingDir = PowerShellContext.UnescapeGlobEscapedPath(workingDir);
263263
try
264264
{
265265
if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory)

src/PowerShellEditorServices/Debugging/DebugService.cs

Lines changed: 1 addition & 1 deletion
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.GlobEscapePath(scriptPath);
182182

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

src/PowerShellEditorServices/Properties/AssemblyInfo.cs

Lines changed: 1 addition & 0 deletions
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

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,7 @@ public async Task ExecuteScriptWithArgs(string script, string arguments = null,
796796
if (File.Exists(script) || File.Exists(scriptAbsPath))
797797
{
798798
// Dot-source the launched script path
799-
script = ". " + EscapePath(script, escapeSpaces: true);
799+
script = ". " + FullyPowerShellEscapePath(script);
800800
}
801801

802802
launchedScript = script + " " + arguments;
@@ -1113,30 +1113,143 @@ public async Task SetWorkingDirectory(string path, bool isPathAlreadyEscaped)
11131113
{
11141114
if (!isPathAlreadyEscaped)
11151115
{
1116-
path = EscapePath(path, false);
1116+
path = GlobEscapePath(path);
11171117
}
11181118

11191119
runspaceHandle.Runspace.SessionStateProxy.Path.SetLocation(path);
11201120
}
11211121
}
11221122

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

1134-
if (escapeSpaces)
1223+
var sb = new StringBuilder(globEscapedPath.Length);
1224+
for (int i = 0; i < globEscapedPath.Length; i++)
11351225
{
1136-
escapedPath = Regex.Replace(escapedPath, @"(?<!`) ", "` ");
1226+
// If we see a backtick perform a lookahead
1227+
char curr = globEscapedPath[i];
1228+
if (curr == '`' && i + 1 < globEscapedPath.Length)
1229+
{
1230+
// If the next char is an escapable one, don't add this backtick to the new string
1231+
char next = globEscapedPath[i + 1];
1232+
switch (next)
1233+
{
1234+
case '[':
1235+
case ']':
1236+
case '?':
1237+
case '*':
1238+
continue;
1239+
1240+
default:
1241+
if (char.IsWhiteSpace(next))
1242+
{
1243+
continue;
1244+
}
1245+
break;
1246+
}
1247+
}
1248+
1249+
sb.Append(curr);
11371250
}
11381251

1139-
return escapedPath;
1252+
return sb.ToString();
11401253
}
11411254

11421255
/// <summary>
@@ -1145,14 +1258,10 @@ public static string EscapePath(string path, bool escapeSpaces)
11451258
/// </summary>
11461259
/// <param name="path">The path to unescape.</param>
11471260
/// <returns>The path with the ` character before [, ] and spaces removed.</returns>
1261+
[Obsolete("This API is not meant for public usage and should not be used.")]
11481262
public static string UnescapePath(string path)
11491263
{
1150-
if (!path.Contains("`"))
1151-
{
1152-
return path;
1153-
}
1154-
1155-
return Regex.Replace(path, @"`(?=[ \[\]])", "");
1264+
return UnescapeGlobEscapedPath(path);
11561265
}
11571266

11581267
#endregion

src/PowerShellEditorServices/Workspace/Workspace.cs

Lines changed: 1 addition & 1 deletion
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.UnescapeGlobEscapedPath(filePath);
377377

378378
// Get the absolute file path
379379
filePath = Path.GetFullPath(filePath);

test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs

Lines changed: 1 addition & 1 deletion
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,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System;
2+
using Xunit;
3+
using Microsoft.PowerShell.EditorServices;
4+
5+
namespace Microsoft.PowerShell.EditorServices.Test.Session
6+
{
7+
public class PathEscapingTests
8+
{
9+
[Theory]
10+
[InlineData("DebugTest.ps1", "DebugTest.ps1")]
11+
[InlineData("../../DebugTest.ps1", "../../DebugTest.ps1")]
12+
[InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "C:\\Users\\me\\Documents\\DebugTest.ps1")]
13+
[InlineData("/home/me/Documents/weird&folder/script.ps1", "/home/me/Documents/weird&folder/script.ps1")]
14+
[InlineData("./path/with some/spaces", "./path/with some/spaces")]
15+
[InlineData("C:\\path\\with[some]brackets\\file.ps1", "C:\\path\\with`[some`]brackets\\file.ps1")]
16+
[InlineData("C:\\look\\an*\\here.ps1", "C:\\look\\an`*\\here.ps1")]
17+
[InlineData("/Users/me/Documents/?here.ps1", "/Users/me/Documents/`?here.ps1")]
18+
[InlineData("/Brackets [and s]paces/path.ps1", "/Brackets `[and s`]paces/path.ps1")]
19+
public void CorrectlyGlobEscapesPaths_NoSpaces(string unescapedPath, string escapedPath)
20+
{
21+
string extensionEscapedPath = PowerShellContext.GlobEscapePath(unescapedPath);
22+
Assert.Equal(escapedPath, extensionEscapedPath);
23+
}
24+
25+
[Theory]
26+
[InlineData("DebugTest.ps1", "DebugTest.ps1")]
27+
[InlineData("../../DebugTest.ps1", "../../DebugTest.ps1")]
28+
[InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "C:\\Users\\me\\Documents\\DebugTest.ps1")]
29+
[InlineData("/home/me/Documents/weird&folder/script.ps1", "/home/me/Documents/weird&folder/script.ps1")]
30+
[InlineData("./path/with some/spaces", "./path/with` some/spaces")]
31+
[InlineData("C:\\path\\with[some]brackets\\file.ps1", "C:\\path\\with`[some`]brackets\\file.ps1")]
32+
[InlineData("C:\\look\\an*\\here.ps1", "C:\\look\\an`*\\here.ps1")]
33+
[InlineData("/Users/me/Documents/?here.ps1", "/Users/me/Documents/`?here.ps1")]
34+
[InlineData("/Brackets [and s]paces/path.ps1", "/Brackets` `[and` s`]paces/path.ps1")]
35+
public void CorrectlyGlobEscapesPaths_Spaces(string unescapedPath, string escapedPath)
36+
{
37+
string extensionEscapedPath = PowerShellContext.GlobEscapePath(unescapedPath, escapeSpaces: true);
38+
Assert.Equal(escapedPath, extensionEscapedPath);
39+
}
40+
41+
[Theory]
42+
[InlineData("DebugTest.ps1", "'DebugTest.ps1'")]
43+
[InlineData("../../DebugTest.ps1", "'../../DebugTest.ps1'")]
44+
[InlineData("C:\\Users\\me\\Documents\\DebugTest.ps1", "'C:\\Users\\me\\Documents\\DebugTest.ps1'")]
45+
[InlineData("/home/me/Documents/weird&folder/script.ps1", "'/home/me/Documents/weird&folder/script.ps1'")]
46+
[InlineData("./path/with some/spaces", "'./path/with some/spaces'")]
47+
[InlineData("C:\\path\\with[some]brackets\\file.ps1", "'C:\\path\\with`[some`]brackets\\file.ps1'")]
48+
[InlineData("C:\\look\\an*\\here.ps1", "'C:\\look\\an`*\\here.ps1'")]
49+
[InlineData("/Users/me/Documents/?here.ps1", "'/Users/me/Documents/`?here.ps1'")]
50+
[InlineData("/Brackets [and s]paces/path.ps1", "'/Brackets `[and s`]paces/path.ps1'")]
51+
[InlineData("/file path/that isn't/normal/", "'/file path/that isn''t/normal/'")]
52+
public void CorrectlyFullyEscapesPaths_Spaces(string unescapedPath, string escapedPath)
53+
{
54+
string extensionEscapedPath = PowerShellContext.FullyPowerShellEscapePath(unescapedPath);
55+
Assert.Equal(escapedPath, extensionEscapedPath);
56+
}
57+
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+
public void CorrectlyUnescapesPaths(string escapedPath, string expectedUnescapedPath)
69+
{
70+
string extensionUnescapedPath = PowerShellContext.UnescapeGlobEscapedPath(escapedPath);
71+
Assert.Equal(expectedUnescapedPath, extensionUnescapedPath);
72+
}
73+
}
74+
}

0 commit comments

Comments
 (0)