Recently, we upgraded a project from an older Java version to a newer one. Many of the changes were routine: update dependencies, replace deprecated APIs, fix a few compiler errors and run the test suite.
One of these changes concerned the invocation of an external Windows program.
The application used the deprecated overload:
Runtime.getRuntime().exec(command);
The command was assembled as one long string. The external program accepted command-line parameters of the following form:
/a="value of A" /b="value of B" /c="value of C"
Because the parameter values could contain spaces, we enclosed them in double quotes. We even had unit tests that explicitly verified the quoting. It was an important detail, or so we thought.
As part of the upgrade, we switched to the recommended overload that accepts the executable and its arguments separately:
Runtime.getRuntime().exec(commandArray);
The migration seemed straightforward. Instead of joining the executable and all parameters into one command string, we put them into a string array:
String[] command = { "program", "/a=\"value of A\"", "/b=\"value of B\"", "/c=\"value of C\""};Runtime.getRuntime().exec(command);
The unit tests were still green. The quotes were still present. Everything looked correct.
Then the application was deployed.
Invalid switch
In production, the external program stopped accepting our invocation. Its only diagnostic message was:
Invalid switch
This was not particularly helpful.
We inspected the parameters. All switches were present. Their spelling was correct. Their order was correct. The values were correct. The quote characters were exactly where our tests expected them to be.
Even more confusingly, the command worked perfectly when entered manually in a Windows command prompt:
program /a="value of A" /b="value of B" /c="value of C"
The executable clearly supported these parameters. The shell command clearly worked. And our Java code appeared to produce the same command.
But it did not.
Asking the receiving program
After spending some time comparing strings and staring at quote characters, we decided to stop reasoning about what the external program ought to receive. Instead, we wrote a small program that showed us what it actually received:
void main(String[] args) { for (var i = 0; i < args.length; i++) { System.out.println("args[" + i + "]: '" + args[i] + "'"); }}
We packaged it as a JAR and invoked it from cmd.exe using the same parameter structure:
java -jar exec-experiment.jar /a="value of A" /b="value of B" /c="value of C"
The output was:
args[0]: '/a=value of A'args[1]: '/b=value of B'args[2]: '/c=value of C'
The double quotes were gone.
This was the missing piece.
The quotes in a shell command are not necessarily characters intended for the receiving program. They are instructions to the command-line parser. They tell it that a sequence containing spaces belongs to one argument.
The value
/a="value of A"
does not mean that the program receives an argument containing two quote characters. It means that the program receives one argument rather than three:
/a=value of A
The quote characters control parsing. They are not part of the resulting argument.
Quotes can appear in surprising places
To verify this interpretation, we performed a slightly more unusual experiment:
java -jar exec-experiment.jar /"a="va"lue of A" /b="value of B" /c="value of C"
This command is certainly not how anybody would normally write the parameters. Nevertheless, its output was unchanged:
args[0]: '/a=value of A'args[1]: '/b=value of B'args[2]: '/c=value of C'
The quotes can surround different portions of a token. Their purpose is to influence how the command line is divided into arguments. Once parsing is complete, they disappear.
This distinction is easy to overlook because a command line is usually presented as a string. It looks as though this string is passed to the program. In reality, there are two different representations involved:
program /a="value of A"
is a textual command line that still needs to be parsed.
By contrast,
new String[] { "program", "/a=value of A"}
already describes the result of that parsing: an executable followed by one complete argument.
We had moved the parsing boundary
With Runtime.exec(String[]), every array element already represents one argument. Spaces inside an element do not split it into additional arguments.
By retaining the quotes, we had changed their meaning. They were no longer syntax interpreted by a shell-like parser. They had become literal characters inside the argument:
/a="value of A"
That was not what the external program expected. It expected:
/a=value of A
The error message “Invalid switch” was therefore accurate, but not very illuminating. The switch looked correct in our logs because we were looking at its command-line representation rather than at the argument format expected by the program.
The fix was simple:
String[] command = { "program", "/a=value of A", "/b=value of B", "/c=value of C"};Runtime.getRuntime().exec(command);
Or, preferably, using ProcessBuilder:
Process process = new ProcessBuilder( "program", "/a=value of A", "/b=value of B", "/c=value of C").start();
After removing the quote characters, the external program worked again.
Ironically, the quotes that our old implementation and its tests had treated as essential were exactly what broke the new implementation.
The takeaway
A command line and an argument array are not interchangeable representations.
When constructing a command line, quoting may be required to preserve spaces during parsing. When constructing an argument array, parsing has already happened conceptually. Each element is one argument, spaces included.
Do not ask:
How would I type this command in a shell?
Ask:
What exact strings should the receiving program find in its argument array?
The answer to the second question is what belongs in Runtime.exec(String[]) or ProcessBuilder.
Sometimes an API migration changes more than a method signature. It moves a boundary – in this case, the boundary between formatting a command line and supplying already separated arguments.
And when that boundary moves, yesterday’s carefully tested solution can become today’s bug.
