Skip to content

ReplaceDeprecatedRuntimeExecMethods: preserve the argument vector - #976

Draft
martinfrancois wants to merge 7 commits into
openrewrite:mainfrom
martinfrancois:fix/runtime-exec-whitespace-tokenization
Draft

ReplaceDeprecatedRuntimeExecMethods: preserve the argument vector#976
martinfrancois wants to merge 7 commits into
openrewrite:mainfrom
martinfrancois:fix/runtime-exec-whitespace-tokenization

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Suggested review order: 17 of 52 (Score: 6.5)
Review first: #966

What's changed?

ReplaceDeprecatedRuntimeExecMethods now writes the same argument array that Runtime#exec(String) builds internally before it passes the arguments on to exec(String[], String[], File).

Before

runtime.exec("printf  '%s'  value");

Actual after the recipe

Using current main.

runtime.exec(new String[]{"printf", "", "'%s'", "", "value"});

Expected after the recipe

The corrected result is shown below.

runtime.exec(new String[]{"printf", "'%s'", "value"});

The recipe now tokenizes the command itself, while the recipe runs, with new StringTokenizer(command), the class Runtime#exec(String) itself uses, and writes the resulting tokens into the source as a fixed new String[]{...}. No StringTokenizer call is written into anyone's source. Each token is escaped before it goes into the JavaTemplate source, so a double quote, a backslash, a control character or a #{ sequence survives the round trip as itself.

The recipe also copies the parameter type list held by the JavaType.Method of the call it is converting before replacing that list's first entry, instead of writing into the shared list.

The set of call shapes the recipe converts is narrower than on main. A call is converted only when every operand of the command expression is a String literal, so exec("ls -a") and exec("ls" + " " + "-a") are still converted, while exec(command) for a String variable, exec(command()) for a method call, and exec(LS + " -a") for a static final String LS are all left exactly as written. Two literal-only cases are also left as written: a command that tokenizes to no tokens at all, such as exec("") or exec(" "), and a literal the parser did not decode, which happens when a supplementary character is written as a pair of unicode escapes, as in the source text runtime.exec("echo \ud83d\ude00x").

The recipe description now states which calls are converted, and this recipe's row in src/main/resources/META-INF/rewrite/recipes.csv, the third file in the diff, was regenerated to match. No other row changed.

What's your motivation?

Recipe: org.openrewrite.staticanalysis.ReplaceDeprecatedRuntimeExecMethods.

The code that main produces runs a different command. All three deprecated String overloads end up in exec(String, String[], File), which tokenizes on any of " \t\n\r\f", collapses runs of them, and ignores leading and trailing ones. String#split(" "), which the recipe uses on main, does none of that: in the example above the argument array has five elements instead of three, two of them empty.

The implementation on main has three further problems, all separate from that tokenization:

  • Token text is not escaped when the recipe writes it back into the source. In the source text runtime.exec("echo \\u0041 b") the backslash is itself escaped, so at run time the command value holds the six characters \, u, 0, 0, 4, 1 between echo and b. On main the recipe writes that token back with a single backslash, so the output source text is runtime.exec(new String[]{"echo", "\u0041", "b"}). There \u0041 is a unicode escape, so the compiler reads the argument as the single letter A, and the compiled program passes a different argument than before with no warning. This change writes "\\u0041" instead, and the argument keeps its six characters.
  • A double quote or a backslash in the command is written back unescaped, so the generated source is not valid Java and the recipe fails on that file instead of rewriting the call. On main the input runtime.exec("echo \"a b\" C:\\dir") produces that failure.
  • A JavaType.Method is interned, so every call of the same overload shares one instance, and on main the recipe writes into that shared instance's parameter type list. Once the first call is converted, MethodMatcher("java.lang.Runtime exec(String)") matches no call of that overload any more, so no later call is converted.

I reproduced all four problems on 2.40.0, the newest release, and on 2.41.0-SNAPSHOT built from main at 5785534, which produce the same output on these inputs, so every statement about main behaviour here holds for 2.40.0 as well.

Confirmed real-world executions

All six executions used org.openrewrite.recipe:rewrite-static-analysis:2.41.0.

Project Stars on 2026-08-16 Location
OpenJDK 23,233 ExecEmptyString.java at b8207347
Dragonwell 8 4,317 ExecEmptyString.java at adf16cc8
Amazon Corretto 8 2,124 ExecEmptyString.java at 65345899
JetBrains Runtime 1,948 ExecEmptyString.java at 995ac3e1
RoboVM 1,636 OldRuntimeTest.java at ef091902
Payara 919 AdminTask.java at 49ae824d

The five runtime-library projects contain regression tests for exec(""). The replacement changes the exception contract. Payara independently builds commands with adjacent spaces; splitting on one literal space adds an empty argument and changes the launched command.

Anything in particular you'd like reviewers to focus on?

Three tests that exist on main asserted output in which the command argument had been rewritten to a .split(" ") call. This change alters those expectations: each now asserts that the call is left exactly as written, and each name gained a doNotChange prefix.

  • stringVariableAsInput is now doNotChangeStringVariableAsInput. It dropped three expected calls, runtime.exec(command.split(" ")) and its envp and envp, dir variants. Its input changed too: runtime, command, envp and dir were locals, including String command = "ls -al";, and are now parameters, so command is a String of unknown value instead of one initialised from a literal. The three runtime.exec(command) calls in the input are themselves unchanged.
  • methodInvocationAsInput is now doNotChangeMethodInvocationAsInput. It dropped the expected runtime.exec(command().split(" ")). The only input change is that Runtime runtime = Runtime.getRuntime(); became a Runtime runtime parameter.
  • concatenatedObjectsAsInput is now doNotChangeConcatenatedObjectsAsInput. It dropped the expected runtime.exec(("ls" + " " + options).split(" ")). The only input change is that same Runtime runtime parameter.

When reading the test diff, note that the first of those renames is not shown as one: the diff replaces stringVariableAsInput in place with a new test, repeatedDelimitersInRawString, and its scenario reappears further down the file as doNotChangeStringVariableAsInput.

This change adds 6 net test methods to ReplaceDeprecatedRuntimeExecMethodsTest, taking the focused class from 9 to 15 executions. Counting 3 renamed tests, 9 new or renamed methods cover 12 scenarios. Without the code change in this pull request, these 8 methods fail and represent 11 failing scenarios:

  • tokenizeRawStrings, covering delimiters, quotes, backslashes, control characters, and template placeholders
  • doNotChangeCommandWithSupplementaryCharacterEscape
  • doNotChangeCommandsThatFailAtRuntime
  • doNotChangeConcatenatedObjectsAsInput
  • doNotChangeMethodInvocationAsInput
  • doNotChangeStringVariableAsInput
  • everyCallOfTheSameOverloadIsReplaced
  • repeatedDelimitersInRawString

rawStringWithSideEffectingEnvironmentAndDirectory passes either way.

Three limitations are worth knowing:

  • A command that concatenates a constant, such as runtime.exec(LS + " -a") where LS is a static final String, is no longer converted at all, because LS reaches the recipe as an identifier rather than as a string literal. main converts it into runtime.exec((LS + " -a").split(" ")), and for LS = "ls" that call builds at run time exactly the two arguments Runtime#exec(String) would have built, so main is not wrong on that value. It is wrong as soon as the concatenated command holds a tab, a repeated separator or a leading separator: runtime.exec(LS + " -a") becomes runtime.exec((LS + " -a").split(" ")) on main, which passes three arguments, the middle one empty. This branch leaves both calls as written.
  • The visitor still has no language guard, so in a Kotlin file the recipe still writes Java syntax such as new String[]{...}. Unchanged from main.
  • A non-ASCII character is written as itself rather than as an escape, so the generated source is not ASCII-only. Unchanged from main.

Have you considered any alternatives or workarounds?

One alternative is to keep converting commands that are not built only from literals by writing command.split("[ \\t\\n\\r\\f]+") into the source instead of leaving those calls alone. That is a small change, but the split call it would write still differs from Runtime#exec(String) in two ways: it leaves an empty first element for a command that starts with a separator, such as " ls -a", and it does not reproduce the IllegalArgumentException("Empty command") that exec("") throws. That is why I skip such calls instead. If you would rather have the recipe keep converting them with that regular expression, say so in review and I will add it.

Any additional context

This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.

I ran the formatter with the repository's .editorconfig. It also wanted to re-indent lines that this change does not touch, so I left those alone and kept the diff limited to this change.

Checklist

`Runtime#exec(String)` tokenizes its command with `StringTokenizer`,
splitting on ' ', '\t', '\n', '\r' and '\f' and collapsing runs of
them. The recipe built the replacement array with `split(" ")`
instead, so `exec("printf  '%s'  value")` became `new
String[]{"printf", "", "'%s'", "", "value"}` and no other whitespace
was split at all. The rewritten call could launch a process with
different arguments than the original.

Tokenize literal commands with `StringTokenizer` itself, and leave
every other command unchanged, since `command.split(" ")` cannot
reproduce the tokenizer at runtime. A command that tokenizes to
nothing is also left alone, because `exec("")` throws
`IllegalArgumentException` where `exec(new String[]{})` throws
`IndexOutOfBoundsException`. Generated tokens are escaped so that
quotes, backslashes, control characters and a literal `#{` survive the
`JavaTemplate` round trip, and a literal the parser did not decode is
declined.

Worth weighing: the recipe can no longer modernize non-literal
commands, which the description and the `recipes.csv` row now state,
and three existing tests that asserted `.split(" ")` output for a
variable, a method invocation and a concatenation with a non-constant
operand now assert no change. The replacement also copies the
parameter type list before overwriting it, because `JavaType.Method`
is interned and that list is a write through view shared with every
other call of the same overload.
@martinfrancois
martinfrancois force-pushed the fix/runtime-exec-whitespace-tokenization branch from 93eabae to 8e69acc Compare August 16, 2026 20:17
@martinfrancois
martinfrancois marked this pull request as draft August 17, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants