Merge branch 'eldar/windows-command-line'

# Conflicts:
#	platform/platform-tests/testSrc/com/intellij/execution/GeneralCommandLineTest.java
This commit is contained in:
Eldar Abusalimov
2017-03-31 17:35:05 +03:00
6 changed files with 595 additions and 78 deletions

View File

@@ -55,6 +55,16 @@ public class ProcessBuilder {
}
// please keep an implementation in sync with [util] CommandLineUtil.toCommandLine()
//
// Comparing to the latter, this is a simplified version with the following limitations on Windows (neither of these
// seems to be used in our cases though):
//
// - does not fully handle \" escaping (must escape quoted ["C:\Program Files\"] -> [\"C:\Program Files\\\"])
// - does not support `cmd.exe /c call command args-with-special-chars-[&<>()@^|]
// - does not handle special chars [&<>()@^|] interleaved with quotes ["] properly (the quote flag)
// - mangles the output of `cmd.exe /c echo ...`
//
// If either of these becomes an issue, please refer to [util] CommandLineUtil.addToWindowsCommandLine() for a possible implementation.
public Process createProcess() throws IOException {
if (myParameters.size() < 1) {
throw new IllegalArgumentException("Executable name not specified");

View File

@@ -332,6 +332,17 @@ public class GeneralCommandLine implements UserDataHolder {
return commands;
}
/**
* Prepares command (quotes and escapes all arguments) and returns it as a newline-separated list.
*
* @return command as a newline-separated list.
* @see #getPreparedCommandLine(Platform)
*/
@NotNull
public String getPreparedCommandLine() {
return getPreparedCommandLine(Platform.current());
}
/**
* Prepares command (quotes and escapes all arguments) and returns it as a newline-separated list
* (suitable e.g. for passing in an environment variable).
@@ -342,7 +353,12 @@ public class GeneralCommandLine implements UserDataHolder {
@NotNull
public String getPreparedCommandLine(@NotNull Platform platform) {
String exePath = myExePath != null ? myExePath : "";
return StringUtil.join(CommandLineUtil.toCommandLine(exePath, myProgramParams.getList(), platform), "\n");
return StringUtil.join(prepareCommandLine(exePath, myProgramParams.getList(), platform), "\n");
}
@NotNull
protected List<String> prepareCommandLine(@NotNull String command, @NotNull List<String> parameters, @NotNull Platform platform) {
return CommandLineUtil.toCommandLine(command, parameters, platform);
}
@NotNull
@@ -353,21 +369,20 @@ public class GeneralCommandLine implements UserDataHolder {
LOG.debug(" charset: " + myCharset);
}
List<String> commands;
try {
checkWorkingDirectory();
if (StringUtil.isEmptyOrSpaces(myExePath)) {
throw new ExecutionException(IdeBundle.message("run.configuration.error.executable.not.specified"));
}
commands = CommandLineUtil.toCommandLine(myExePath, myProgramParams.getList());
}
catch (ExecutionException e) {
LOG.info(e);
throw e;
}
List<String> commands = prepareCommandLine(myExePath, myProgramParams.getList(), Platform.current());
try {
return startProcess(commands);
}
@@ -377,9 +392,25 @@ public class GeneralCommandLine implements UserDataHolder {
}
}
/**
* @implNote for subclasses:
* On Windows the escapedCommands argument must never be modified or augmented in any way.
* Windows command line handling is extremely fragile and vague, and the exact escaping of a particular argument may vary
* depending on values of the preceding arguments.
*
* [foo] [^] -> [foo] [^^]
*
* but:
* [foo] ["] [^] -> [foo] [\"] ["^"]
*
* Notice how the last parameter escaping changes after prepending another argument.
*
* If you need to alter the command line passed in, override the {@link #prepareCommandLine(String, List, Platform)} method instead.
*/
@NotNull
protected Process startProcess(@NotNull List<String> commands) throws IOException {
ProcessBuilder builder = new ProcessBuilder(commands);
protected Process startProcess(@NotNull List<String> escapedCommands) throws IOException {
ProcessBuilder builder = new ProcessBuilder(escapedCommands);
setupEnvironment(builder.environment());
builder.directory(myWorkDirectory);
builder.redirectErrorStream(myRedirectErrorStream);

View File

@@ -24,7 +24,6 @@ import com.intellij.openapi.util.SystemInfo;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.testFramework.PlatformTestUtil;
import com.intellij.util.ArrayUtil;
import com.intellij.util.containers.ContainerUtil;
import org.intellij.lang.annotations.MagicConstant;
import org.jetbrains.annotations.NotNull;
@@ -43,8 +42,9 @@ import static org.junit.Assert.*;
import static org.junit.Assume.assumeTrue;
public class GeneralCommandLineTest {
private static final String[] ARGUMENTS = {
public static final String[] ARGUMENTS = {
"with space",
" leading and trailing spaces ",
"\"quoted\"",
"\"quoted with spaces\"",
"",
@@ -55,8 +55,52 @@ public class GeneralCommandLineTest {
"space \"and \"quotes\" inside",
"\"space \"and \"quotes\" inside\"",
"param2",
"\\backslash",
"trailing slash\\",
(SystemInfo.isWindows ? "windows_sucks" : "two trailing slashes\\\\")
"two trailing slashes\\\\",
"trailing-slash\\",
"two-trailing-slashes\\\\",
"\"quoted slash\\\"",
"\"quoted two slashes\\\\\"",
"\"quoted-slash\\\"",
"\"quoted-two-slashes\\\\\"",
"some\ttab",
"^% % %%% %\"%%",
//"%PATH%",
"^",
"\\^",
"^ ^^",
"specials \\ & | > < ^",
"carets: ^ ^^ ^^^ ^^^^",
"caret escape ^\\ ^& ^| ^> ^< ^^",
"caret escape2 ^^\\ ^^& ^^| ^^> ^^< ^^^",
"&<>()@^|",
"\"^\"",
"\"^\"^\"",
"\"^\"\"^^\"^^^",
"\"^&<>(\")@^|\"",
" < \" > ",
" \" ^ \" ",
" \" ^ \" ^ \" ",
" \" ^ \" \" ^ ^\" ^^^ ",
" \" ^ &< >( \" ) @ ^ | \" ",
" < \" > ",
"*",
"\\*",
"\"*\"",
"*.*",
"?",
"???",
"??????",
"????????", // testData
"??????????",
"????????????",
"??????????????",
"????????????????",
"??????????????????", // platform-tests.iml
"\\?",
"\"?\"",
"*.???", // ^ the Xmas tree above is to catch at least one file matching those globs
};
@SuppressWarnings("SpellCheckingInspection") private static final String UNICODE_RU = "Юникоде";
@@ -141,10 +185,9 @@ public class GeneralCommandLineTest {
@Test(timeout = 60000)
public void passingArgumentsToJavaApp() throws Exception {
String[] args = ArrayUtil.mergeArrays(ARGUMENTS, "&<>()@^|", "\"&<>()@^|\"");
Pair<GeneralCommandLine, File> command = makeHelperCommand(null, CommandTestHelper.ARG, args);
Pair<GeneralCommandLine, File> command = makeHelperCommand(null, CommandTestHelper.ARG, ARGUMENTS);
String output = execHelper(command);
checkParamPassing(output, args);
checkParamPassing(output, ARGUMENTS);
}
@Test(timeout = 60000)
@@ -159,6 +202,21 @@ public class GeneralCommandLineTest {
checkParamPassing(output, ARGUMENTS);
}
@Test(timeout = 60000)
public void passingArgumentsToJavaAppThroughNestedWinShell() throws Exception {
assumeTrue(SystemInfo.isWindows);
Pair<GeneralCommandLine, File> command = makeHelperCommand(null, CommandTestHelper.ARG, ARGUMENTS);
String javaPath = command.first.getExePath();
command.first.setExePath(ExecUtil.getWindowsShellName());
command.first.getParametersList().prependAll("/D", "/C", "call",
ExecUtil.getWindowsShellName(), "/D", "/C", "call",
ExecUtil.getWindowsShellName(), "/D", "/C", "@call",
javaPath);
String output = execHelper(command);
checkParamPassing(output, ARGUMENTS);
}
@Test(timeout = 60000)
public void passingArgumentsToJavaAppThroughCmdScriptAndWinShell() throws Exception {
assumeTrue(SystemInfo.isWindows);
@@ -176,6 +234,38 @@ public class GeneralCommandLineTest {
}
}
@Test(timeout = 60000)
public void passingArgumentsToJavaAppThroughCmdScriptAndNestedWinShell() throws Exception {
assumeTrue(SystemInfo.isWindows);
Pair<GeneralCommandLine, File> command = makeHelperCommand(null, CommandTestHelper.ARG);
File script = ExecUtil.createTempExecutableScript("my script ", ".cmd", "@" + command.first.getCommandLineString() + " %*");
try {
GeneralCommandLine commandLine = createCommandLine(ExecUtil.getWindowsShellName(), "/D", "/C", "call",
ExecUtil.getWindowsShellName(), "/D", "/C", "@call",
ExecUtil.getWindowsShellName(), "/D", "/C", "call",
script.getAbsolutePath());
commandLine.addParameters(ARGUMENTS);
String output = execHelper(pair(commandLine, command.second));
checkParamPassing(output, ARGUMENTS);
}
finally {
FileUtil.delete(script);
}
}
@Test(timeout = 60000)
public void passingArgumentsToEchoThroughWinShell() throws Exception {
assumeTrue(SystemInfo.isWindows);
for (String argument : ARGUMENTS) {
if (argument.trim().isEmpty()) continue; // would report "ECHO is on"
GeneralCommandLine commandLine = new GeneralCommandLine(ExecUtil.getWindowsShellName(), "/D", "/C", "echo", argument);
String output = execAndGetOutput(commandLine);
assertEquals(commandLine.getPreparedCommandLine(), argument + "\n", output);
}
}
@Test(timeout = 60000)
public void unicodeParameters() throws Exception {
assumeTrue(UNICODE != null);
@@ -192,7 +282,7 @@ public class GeneralCommandLineTest {
String string = "http://localhost/wtf?a=b&c=d";
String echo = ExecUtil.execAndReadLine(createCommandLine(ExecUtil.getWindowsShellName(), "/c", "echo", string));
assertEquals('"' + string + '"', echo);
assertEquals(string, echo);
}
@Test(timeout = 60000)
@@ -201,14 +291,13 @@ public class GeneralCommandLineTest {
String scriptPrefix = "my_script";
for (String scriptExt : new String[]{".cmd", ".bat"}) {
File script = ExecUtil.createTempExecutableScript(scriptPrefix, scriptExt, "@echo %1\n");
String param = "a&b";
GeneralCommandLine commandLine = createCommandLine(script.getAbsolutePath(), param);
String text = commandLine.getPreparedCommandLine(Platform.WINDOWS);
assertEquals(commandLine.getExePath() + "\n" + StringUtil.wrapWithDoubleQuote(param), text);
File script = ExecUtil.createTempExecutableScript(scriptPrefix, scriptExt, "@echo %*\n");
try {
String output = execAndGetOutput(commandLine);
assertEquals(StringUtil.wrapWithDoubleQuote(param), output.trim());
for (String argument : ARGUMENTS) {
GeneralCommandLine commandLine = createCommandLine(script.getAbsolutePath(), GeneralCommandLine.inescapableQuote(argument));
String output = execAndGetOutput(commandLine);
assertEquals(commandLine.getPreparedCommandLine(), StringUtil.wrapWithDoubleQuote(argument), output.trim());
}
}
finally {
FileUtil.delete(script);
@@ -223,7 +312,7 @@ public class GeneralCommandLineTest {
String param = "a&b";
GeneralCommandLine commandLine = createCommandLine(ExecUtil.getWindowsShellName(), "/D", "/C", "echo", param);
String output = execAndGetOutput(commandLine);
assertEquals(StringUtil.wrapWithDoubleQuote(param), output.trim());
assertEquals(param, output.trim());
}
@Test(timeout = 60000)
@@ -301,8 +390,11 @@ public class GeneralCommandLineTest {
ProcessOutput output = ExecUtil.execAndGetOutput(commandLine);
int ec = output.getExitCode();
if (ec != 0) {
fail("Command:\n" + commandLine.getCommandLineString() + "\nStdOut:\n" + output.getStdout() + "\nStdErr:\n" + output.getStderr());
fail("Command:\n" + commandLine.getPreparedCommandLine() +
"\nStdOut:\n" + output.getStdout() +
"\nStdErr:\n" + output.getStderr());
}
assertTrue(output.getStderr(), output.getStderr().isEmpty());
return output.getStdout();
}
@@ -396,4 +488,4 @@ public class GeneralCommandLineTest {
fail("% missed: " + pctMissed + ", missed: " + missed + ", passed: " + lines);
}
}
}
}

View File

@@ -16,21 +16,32 @@
package com.intellij.execution;
import com.intellij.openapi.util.io.FileUtilRt;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.util.containers.ContainerUtil;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.intellij.openapi.util.text.StringUtil.*;
public class CommandLineUtil {
private static final char SPECIAL_QUOTE = '\uEFEF';
private static final String WIN_SHELL_SPECIALS = "&<>()@^|";
private static final char INESCAPABLE_QUOTE = '\uEFEF'; // a random char, which is unlikely to encounter in an argument
private static final Pattern WIN_BACKSLASHES_PRECEDING_QUOTE = Pattern.compile("(\\\\+)(?=\"|$)");
private static final Pattern WIN_CARET_SPECIAL = Pattern.compile("[&<>()@^|]");
private static final Pattern WIN_QUOTE_SPECIAL = Pattern.compile("[ \t\"*?]");
private static final Pattern WIN_QUIET_COMMAND = Pattern.compile("((?:@\\s*)++)(.*)", Pattern.CASE_INSENSITIVE);
private static final char Q = '\"';
private static final String QQ = "\"\"";
@NotNull
public static String specialQuote(@NotNull String parameter) {
return quote(parameter, SPECIAL_QUOTE);
return quote(parameter, INESCAPABLE_QUOTE);
}
@NotNull
@@ -51,47 +62,399 @@ public class CommandLineUtil {
commandLine.add(FileUtilRt.toSystemDependentName(command, platform.fileSeparator));
boolean isWindows = platform == Platform.WINDOWS;
boolean winShell = isWindows && isWinShell(command);
for (String parameter : parameters) {
if (isWindows) {
if (parameter.contains("\"")) {
parameter = StringUtil.replace(parameter, "\"", "\\\"");
}
else if (parameter.isEmpty()) {
parameter = "\"\"";
if (platform != Platform.WINDOWS) {
for (String parameter : parameters) {
if (isQuoted(parameter, INESCAPABLE_QUOTE)) {
// TODO do we need that on non-Windows? M.b. just remove these quotes? -- Eldar
parameter = quote(unquoteString(parameter, INESCAPABLE_QUOTE), Q);
}
commandLine.add(parameter);
}
if (winShell && hasWinShellSpecialChars(parameter)) {
parameter = quote(parameter, SPECIAL_QUOTE);
}
if (isQuoted(parameter, SPECIAL_QUOTE)) {
parameter = quote(parameter.substring(1, parameter.length() - 1), '"');
}
commandLine.add(parameter);
}
else {
addToWindowsCommandLine(parameters, commandLine);
}
return commandLine;
}
/*
* Windows command line escaping rules are tricky and poorly documented, so the code below might require a bit of explanation.
*
* Here're the rules that define our implementation, and some peculiarities to know:
*
* *** On Windows, there's no ARGV concept at the OS level; all parameters are passed as a single command line
* string, which is only parsed into the ARGV by CRT if needed (this is used by the most applications).
*
* CRT parsing rules are relatively simple:
*
* - Parameters are delimited using spaces: [foo] [bar] [baz] -> [foo bar baz]
* - Whitespaces are escaped using double quotes: [foo bar] [baz] -> ["foo bar" baz]
* - Double-quotes are escaped using backslashes: [foo bar] ["baz"] -> ["foo bar" \"baz\"]
*
* - Backslashes are treated literally unless they precede a double quote, otherwise they need to be
* backslash-escaped as well:
*
* [C:\Program Files\] ["backslash quote\"]
*
* -> ["C:\Program Files\\" "\"backslash quote\\\""]
*
*
* *** In case a command line is wrapped using CMD.EXE call (that is, `cmd /d /c call executable args...`, which
* is quite common), additional escaping rules apply.
*
* CMD.EXE treat few chars in a special way (piping, command chaining, etc.), these are: [&<>()@|^].
* The CMD.EXE command line parser has two means of escaping these special chars: quote flag and caret-escaping.
* The main rules in a nutshell are:
*
* - A quote ["] toggles the quote flag; while the quote flag is active, the special chars are no longer
* special. Note that quotes themselves are NOT removed at this stage: ["<^.^>"] => ["<^.^>"]
* The quotes might be removed by the CRT command line parser later on when reconstructing the ARGV,
* if the executable opts to.
*
* - A char following a caret [^] has no special meaning, the caret itself is removed: [^<^^.^^^>] => [<^.^>]
*
* These rules, in turn, have special cases that affect the implementation, see below.
*
*
* *** [CMD] As already mentioned, the CMD special chars [&<>()@|^] are sensitive to the quote flag, which is toggled
* whenever the command line parser encounters a quote, no matter whether backslash-escaped or regular one:
*
* [foo "bar" baz]
*
* -> ["foo \"bar\" baz"] # enclosed in quotes due to whitespaces inside for CRT
* # quote flag:
* [ ^^^^^ ^^^^ ] # ON: [&<>()@|^] lose special meaning
* [ ^^^^ ] # OFF: [&<>()@|^] must be ^-escaped
*
* This gets even more confusing when dealing with caret-escaping state across multiple arguments in case some
* of them have odd number of quotes:
*
* [C:\Program Files\...] ["] [f o] [b"r]
*
* -> ["C:\Program Files\..." \" "f o" b\"r]
* # quote flag:
* [ ^^^^^^^^^^^^^^^^^^^^ ^^^ ^^^^^ ] # ON: [&<>()@|^] lose special meaning
* [ ^^^^ ^^^ ^] # OFF: [&<>()@|^] must be ^-escaped
*
* However, this is a totally valid case considering that arguments are passed as a single command line string
* under the hood. Anyway, the point is, we need to count all the quotes in order to properly escape the special
* chars. In the following sections we describe our escaping approach w.r.t. the quote flag.
*
*
* *** [CMD] A caret [^] is always ^-escaped with the quote flag OFF:
*
* [^] # original value
* -> [^^] # escaping
* -> [^] # execution
*
* Why not just use a caret within quotes ["^"] (i.e. with the quote flag ON) here?
*
* Because of the way CMD.EXE handles the CALL command. Due to the nature of the CALL command, CMD.EXE has
* to process the ^-escaped special chars twice. In order to preserve the number of carets, which would be
* halved after the second expansion otherwise, it duplicates all the carets behind the scenes beforehand:
*
* [^] # original value
* -> [^^] # escaping
* -> [^^^^] # under the hood: CALL caret doubling
* -> [^^] # under the hood: CALL second expansion
* -> [^] # execution
*
* Unfortunately it blindly doubles all carets, with no regard to the quote flag. But a quoted caret is not
* treated as an escape, hence it's not consumed. These carets appear duplicated to a process being called:
*
* [^] # original value
* -> ["^"] # escaping
* -> ["^^"] # under the hood: CALL caret doubling
* -> ["^^"] # under the hood: CALL second expansion
* -> [^^] # execution (oops...)
*
*
* *** [CMD] The rest special chars ([&<>()@|] except the caret) are quoted instead (i.e. emitted with the quote
* flag ON) instead of being ^-escaped (with the only exception of ECHO command, see below):
*
* [&] # original value
* -> ["&"] # escaping
* -> [&] # execution
*
* Why not use ^-escaping, just like for the caret itself? Again, because of the CALL caret doubling:
*
* [&] # original value
* -> [^&] # escaping
* -> [^^&] # under the hood: CALL caret doubling
* -> [^] # under the hood: CALL second expansion (stray [&] may lead to errors)
* -> [] # execution (oops...)
*
* *** [CMD] [ECHO] The ECHO command doesn't use CRT for parsing the ARGV, so only the common rules for CMD parameters
* apply. That is, we ^-escape all the special chars [&<>()@|^].
*
*
* Useful links:
*
* * https://ss64.com/nt/syntax-esc.html Syntax : Escape Characters, Delimiters and Quotes
* * http://stackoverflow.com/a/4095133/545027 How does the Windows Command Interpreter (CMD.EXE) parse scripts?
*/
private static void addToWindowsCommandLine(@NotNull List<String> parameters,
@NotNull List<String> commandLine) {
String command = commandLine.get(0);
boolean isCmdParam = isWinShell(command);
int cmdInvocationDepth = isWinShellScript(command) ? 2 : isCmdParam ? 1 : 0;
QuoteFlag quoteFlag = new QuoteFlag(command);
for (int i = 0; i < parameters.size(); i++) {
String parameter = parameters.get(i);
parameter = unquoteString(parameter, INESCAPABLE_QUOTE);
boolean inescapableQuoting = !parameter.equals(parameters.get(i));
if (parameter.isEmpty()) {
commandLine.add(QQ);
continue;
}
if (isCmdParam && parameter.startsWith("/") && parameter.length() == 2) {
commandLine.add(parameter);
continue;
}
String parameterPrefix = "";
if (isCmdParam) {
Matcher m = WIN_QUIET_COMMAND.matcher(parameter);
if (m.matches()) {
parameterPrefix = m.group(1); // @...
parameter = m.group(2);
}
if (parameter.equalsIgnoreCase("echo")) {
// no further quoting, only ^-escape and wrap the whole "echo ..." into double quotes
String parametersJoin = join(ContainerUtil.subList(parameters, i), " ");
parameter = escapeParameter(parametersJoin, quoteFlag.toggle(), cmdInvocationDepth, false);
commandLine.add(parameter); // prefix is already included
break;
}
if (!parameter.equalsIgnoreCase("call")) {
isCmdParam = isWinShell(parameter);
if (isCmdParam || isWinShellScript(parameter)) {
cmdInvocationDepth++;
}
}
}
if (cmdInvocationDepth > 0 && !isCmdParam || inescapableQuoting) {
parameter = escapeParameter(parameter, quoteFlag, cmdInvocationDepth, !inescapableQuoting);
}
else {
parameter = backslashEscapeQuotes(parameter);
}
commandLine.add(parameterPrefix + parameter);
}
}
@NotNull
@Contract(pure = true)
public static String escapeParameterOnWindows(@NotNull String s, boolean isWinShell) {
boolean hadLineBreaks = !s.equals(s = convertLineSeparators(s, ""));
if (s.isEmpty()) return QQ;
String ret = isWinShell ? escapeParameter(s, new QuoteFlag(hadLineBreaks), 1, true) : backslashEscapeQuotes(s);
return hadLineBreaks ? quote(ret, Q) : ret;
}
@NotNull
@Contract(pure = true)
private static String escapeParameter(@NotNull String s,
@NotNull QuoteFlag quoteFlag,
int cmdInvocationDepth,
boolean escapeQuotingInside) {
final StringBuilder sb = new StringBuilder();
final String escapingCarets = repeatSymbol('^', (1 << cmdInvocationDepth) - 1);
return (escapeQuotingInside ? quoteEscape(sb, s, quoteFlag, escapingCarets)
: caretEscape(sb, s, quoteFlag, escapingCarets)).toString();
}
/**
* Escape a parameter for passing it to CMD.EXE, that is only ^-escape and wrap it with double quotes,
* but do not touch any double quotes or backslashes inside.
*
* @param escapingCarets 2^n-1 carets for escaping the special chars
* @return sb
*/
@NotNull
private static StringBuilder caretEscape(@NotNull StringBuilder sb,
@NotNull String s,
@NotNull QuoteFlag quoteFlag,
@NotNull String escapingCarets) {
quoteFlag.toggle();
sb.append(Q);
int lastPos = 0;
final Matcher m = WIN_CARET_SPECIAL.matcher(s);
while (m.find()) {
quoteFlag.consume(s, lastPos, m.start());
sb.append(s, lastPos, m.start());
if (!quoteFlag.isEnabled()) sb.append(escapingCarets);
sb.append(m.group());
lastPos = m.end();
}
quoteFlag.consume(s, lastPos, s.length());
sb.append(s, lastPos, s.length());
quoteFlag.toggle();
return sb.append(Q);
}
/**
* Escape a parameter for passing it to Windows application through CMD.EXE, that is ^-escape + quote.
*
* @param escapingCarets 2^n-1 carets for escaping the special chars
* @return sb
*/
@NotNull
private static StringBuilder quoteEscape(@NotNull StringBuilder sb,
@NotNull String s,
@NotNull QuoteFlag quoteFlag,
@NotNull String escapingCarets) {
int lastPos = 0;
final Matcher m = WIN_CARET_SPECIAL.matcher(s);
while (m.find()) {
quoteFlag.consume(s, lastPos, m.start());
appendQuoted(sb, s.substring(lastPos, m.start()));
String specialText = m.group();
boolean isCaret = specialText.equals("^");
if (isCaret) specialText = escapingCarets + specialText; // only a caret is escaped using carets
if (isCaret == quoteFlag.isEnabled()) {
// a caret must be always outside quotes: close a quote temporarily, put a caret, reopen a quote
// rest special chars are always inside: quote and append them as usual
appendQuoted(sb, specialText); // works for both cases
}
else {
sb.append(specialText);
}
lastPos = m.end();
}
quoteFlag.consume(s, lastPos, s.length());
appendQuoted(sb, s.substring(lastPos));
// JDK ProcessBuilder implementation on Windows checks each argument to contain whitespaces and encloses it in
// double quotes unless it's already quoted. Since our escaping logic is more complicated, we may end up with
// something like [^^"foo bar"], which is not a quoted string, strictly speaking; it would require additional
// quotes from the JDK's point of view, which in turn ruins the quoting and caret state inside the string.
//
// Here, we prepend and/or append a [""] token (2 quotes to preserve the parity), if necessary, to make the result
// look like a properly quoted string:
//
// [^^"foo bar"] -> [""^^"foo bar"] # starts and ends with quotes
//
if (!isQuoted(sb, Q) && indexOfAny(sb, " \t") >= 0) {
if (sb.charAt(0) != Q) sb.insert(0, QQ);
if (sb.charAt(sb.length() - 1) != Q) sb.append(QQ);
}
return sb;
}
/**
* Appends the string to the buffer, quoting the former if necessary.
*
* @return sb
*/
@SuppressWarnings("UnusedReturnValue")
private static StringBuilder appendQuoted(@NotNull StringBuilder sb, @NotNull String s) {
if (s.isEmpty()) return sb;
s = backslashEscapeQuotes(s);
if (WIN_CARET_SPECIAL.matcher(s).find()) s = quote(s, Q);
// Can't just concatenate two quoted strings, like ["foo"] and ["bar"],
// the two quotes inside would be treated as ["] upon unescaping, that is:
//
// ["foo""bar"] -> [foo"bar] # [""""] -> ["] (oops...)
//
int numTrailingBackslashes = removeClosingQuote(sb);
if (numTrailingBackslashes < 0) {
// sb was not quoted at its end
return sb.append(s);
}
s = unquoteString(s, Q);
if (WIN_BACKSLASHES_PRECEDING_QUOTE.matcher(s).matches()) {
// those trailing backslashes left in the buffer (if any) are going to precede a quote, double them
repeatSymbol(sb, '\\', numTrailingBackslashes);
}
return sb.append(s)
.append(Q);
}
@NotNull
@Contract(pure = true)
private static String backslashEscapeQuotes(@NotNull String s) {
assert !s.isEmpty();
String result = WIN_BACKSLASHES_PRECEDING_QUOTE.matcher(s)
.replaceAll("$1$1") // duplicate trailing backslashes and those preceding a double quote
.replace("\"", "\\\""); // backslash-escape all double quotes
if (!result.equals(s) || WIN_QUOTE_SPECIAL.matcher(s).find()) {
result = quote(result, Q);
}
return result;
}
/**
* If the buffer ends with a double-quoted token, "reopen" it by deleting the closing quote.
*
* @param sb the buffer being modified
* @return -1 if the buffer doesn't end with a quotation (in this case it's left unmodified),
* otherwise a number of trailing backslashes left in the buffer, if any
*/
private static int removeClosingQuote(@NotNull StringBuilder sb) {
if (sb.length() < 2 || sb.charAt(sb.length() - 1) != Q) return -1;
// Strip the closing quote and halve the number of trailing backslashes (if any):
// they no more precede a double quote, hence lose their special treatment during unescaping.
sb.setLength(sb.length() - 1);
int numTrailingBackslashes = sb.length() - trimTrailing(sb, '\\').length();
repeatSymbol(sb, '\\', numTrailingBackslashes / 2);
if (numTrailingBackslashes % 2 == 1) {
// retreat, it was a backslash-escaped quote, restore it
repeatSymbol(sb, '\\', numTrailingBackslashes / 2 + 1);
sb.append(Q);
return -1;
}
return numTrailingBackslashes / 2;
}
private static boolean isWinShell(@NotNull String command) {
return endsWithIgnoreCase(command, ".cmd") || endsWithIgnoreCase(command, ".bat") ||
"cmd".equalsIgnoreCase(command) || "cmd.exe".equalsIgnoreCase(command);
return "cmd".equalsIgnoreCase(command) || "cmd.exe".equalsIgnoreCase(command);
}
private static boolean isWinShellScript(@NotNull String command) {
return endsWithIgnoreCase(command, ".cmd") || endsWithIgnoreCase(command, ".bat");
}
private static boolean endsWithIgnoreCase(@NotNull String str, @NotNull String suffix) {
return str.regionMatches(true, str.length() - suffix.length(), suffix, 0, suffix.length());
}
private static String quote(String s, char ch) {
@NotNull
private static String quote(@NotNull String s, char ch) {
return !isQuoted(s, ch) ? ch + s + ch : s;
}
private static boolean isQuoted(String s, char ch) {
private static boolean isQuoted(@NotNull CharSequence s, char ch) {
return s.length() >= 2 && s.charAt(0) == ch && s.charAt(s.length() - 1) == ch;
}
@@ -100,7 +463,7 @@ public class CommandLineUtil {
public static String extractPresentableName(@NotNull String commandLine) {
String executable = commandLine.trim();
List<String> words = StringUtil.splitHonorQuotes(executable, ' ');
List<String> words = splitHonorQuotes(executable, ' ');
String execName;
List<String> args;
if (words.isEmpty()) {
@@ -113,13 +476,49 @@ public class CommandLineUtil {
}
if (VERBOSE_COMMAND_LINE_MODE) {
return StringUtil.shortenPathWithEllipsis(execName + " " + StringUtil.join(args, " "), 250);
return shortenPathWithEllipsis(execName + " " + join(args, " "), 250);
}
return new File(StringUtil.unquoteString(execName)).getName();
return new File(unquoteString(execName)).getName();
}
public static boolean hasWinShellSpecialChars(String parameter) {
return StringUtil.containsAnyChar(parameter, WIN_SHELL_SPECIALS);
return WIN_CARET_SPECIAL.matcher(parameter).find();
}
}
/**
* Counts quote parity needed to ^-escape special chars on Windows properly.
*/
private static class QuoteFlag {
private boolean myValue;
public QuoteFlag(boolean value) {
myValue = value;
}
private QuoteFlag(@NotNull CharSequence s) {
consume(s);
}
public boolean isEnabled() {
return myValue;
}
@NotNull
public QuoteFlag toggle() {
myValue = !myValue;
return this;
}
@NotNull
public QuoteFlag consume(@NotNull CharSequence s) {
return consume(s, 0, s.length());
}
@NotNull
public QuoteFlag consume(@NotNull CharSequence s, int start, int end) {
myValue ^= countChars(s, Q, start, end, false) % 2 != 0; // count all, no matter whether backslash-escaped or not
return this;
}
}
}

View File

@@ -47,6 +47,10 @@ public enum GitVersionSpecialty {
}
},
/**
* @deprecated on Windows, quotes are now added automatically whenever necessary on the GeneralCommandLine level
*/
@Deprecated
NEEDS_QUOTES_IN_STASH_NAME {
@Override
public boolean existsIn(@NotNull GitVersion version) {

View File

@@ -17,7 +17,6 @@ package git4idea.ui;
import com.intellij.CommonBundle;
import com.intellij.dvcs.DvcsUtil;
import com.intellij.execution.configurations.GeneralCommandLine;
import com.intellij.notification.Notification;
import com.intellij.notification.NotificationListener;
import com.intellij.openapi.application.AccessToken;
@@ -43,10 +42,8 @@ import com.intellij.ui.DocumentAdapter;
import com.intellij.util.Consumer;
import git4idea.GitRevisionNumber;
import git4idea.GitUtil;
import git4idea.GitVcs;
import git4idea.branch.GitBranchUtil;
import git4idea.commands.*;
import git4idea.config.GitVersionSpecialty;
import git4idea.i18n.GitBundle;
import git4idea.merge.GitConflictResolver;
import git4idea.repo.GitRepository;
@@ -92,14 +89,12 @@ public class GitUnstashDialog extends DialogWrapper {
private final HashSet<String> myBranches = new HashSet<>();
private final Project myProject;
private GitVcs myVcs;
private static final Logger LOG = Logger.getInstance(GitUnstashDialog.class);
public GitUnstashDialog(final Project project, final List<VirtualFile> roots, final VirtualFile defaultRoot) {
super(project, true);
setModal(false);
myProject = project;
myVcs = GitVcs.getInstance(project);
setTitle(GitBundle.getString("unstash.title"));
setOKButtonText(GitBundle.getString("unstash.button.apply"));
setCancelButtonText(CommonBundle.getCloseButtonText());
@@ -172,8 +167,7 @@ public class GitUnstashDialog extends DialogWrapper {
private GitSimpleHandler dropHandler(String stash) {
GitSimpleHandler h = new GitSimpleHandler(myProject, getGitRoot(), GitCommand.STASH);
h.addParameters("drop");
addStashParameter(h, stash);
h.addParameters("drop", stash);
return h;
}
});
@@ -185,8 +179,7 @@ public class GitUnstashDialog extends DialogWrapper {
try {
GitSimpleHandler h = new GitSimpleHandler(project, root, GitCommand.REV_LIST);
h.setSilent(true);
h.addParameters("--timestamp", "--max-count=1");
addStashParameter(h, selectedStash);
h.addParameters("--timestamp", "--max-count=1", selectedStash);
h.endOptions();
final String output = h.run();
resolvedStash = GitRevisionNumber.parseRevlistOutputAsRevisionNumber(h, output).asString();
@@ -202,18 +195,6 @@ public class GitUnstashDialog extends DialogWrapper {
updateDialogState();
}
/**
* Adds {@code stash@{x}} parameter to the handler, quotes it if needed.
*/
private void addStashParameter(@NotNull GitHandler handler, @NotNull String stash) {
if (GitVersionSpecialty.NEEDS_QUOTES_IN_STASH_NAME.existsIn(myVcs.getVersion())) {
handler.addParameters(GeneralCommandLine.inescapableQuote(stash));
}
else {
handler.addParameters(stash);
}
}
/**
* Update state dialog depending on the current state of the fields
*/
@@ -312,7 +293,7 @@ public class GitUnstashDialog extends DialogWrapper {
h.addParameters("branch", branch);
}
String selectedStash = getSelectedStash().getStash();
addStashParameter(h, selectedStash);
h.addParameters(selectedStash);
return h;
}