Merge "Fix escaping and grouping of arguments to the Target shells"
diff --git a/src/vogar/LocalTarget.java b/src/vogar/LocalTarget.java
index 5e194c4..d15bf7c 100644
--- a/src/vogar/LocalTarget.java
+++ b/src/vogar/LocalTarget.java
@@ -30,6 +30,8 @@
*/
public final class LocalTarget extends Target {
+ private static final ImmutableList<String> TARGET_PROCESS_PREFIX = ImmutableList.of("sh", "-c");
+
private final Log log;
private final Mkdir mkdir;
@@ -46,8 +48,8 @@
return new File("/tmp/vogar");
}
- @Override public List<String> targetProcessPrefix() {
- return ImmutableList.of("sh", "-c");
+ @Override protected ImmutableList<String> targetProcessPrefix() {
+ return TARGET_PROCESS_PREFIX;
}
@Override public String getDeviceUserName() {
diff --git a/src/vogar/Run.java b/src/vogar/Run.java
index 7cadb3a..b4ff076 100644
--- a/src/vogar/Run.java
+++ b/src/vogar/Run.java
@@ -314,13 +314,11 @@
}
/**
- * Returns an environment variable assignment to configure where the VM will
- * store its dexopt files. This must be set on production devices and is
- * optional for development devices.
+ * Returns the directory where the VM stores its dexopt files.
*/
- public String getAndroidData() {
+ public String getAndroidDataPath() {
// The VM wants the parent directory of a directory named "dalvik-cache"
- return "ANDROID_DATA=" + dalvikCache().getParentFile();
+ return dalvikCache().getParentFile().getPath();
}
/**
diff --git a/src/vogar/SshTarget.java b/src/vogar/SshTarget.java
index c70ccd0..94177db 100644
--- a/src/vogar/SshTarget.java
+++ b/src/vogar/SshTarget.java
@@ -56,7 +56,7 @@
return new File("/data/local/tmp/vogar");
}
- @Override public List<String> targetProcessPrefix() {
+ @Override protected ImmutableList<String> targetProcessPrefix() {
return sshCommandPrefixList;
}
diff --git a/src/vogar/Target.java b/src/vogar/Target.java
index da414ff..78fa297 100644
--- a/src/vogar/Target.java
+++ b/src/vogar/Target.java
@@ -16,16 +16,21 @@
package vogar;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
import java.io.File;
import java.io.FileNotFoundException;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import vogar.tasks.Task;
/**
* A target runtime environment such as a remote device or the local host
*/
public abstract class Target {
- public abstract List<String> targetProcessPrefix();
+ protected abstract ImmutableList<String> targetProcessPrefix();
public abstract String getDeviceUserName();
public abstract List<File> ls(File directory) throws FileNotFoundException;
@@ -53,4 +58,127 @@
}
};
}
+
+ /**
+ * Create a {@link ScriptBuilder} appropriate for this target.
+ */
+ public ScriptBuilder newScriptBuilder() {
+ return new ScriptBuilder(targetProcessPrefix());
+ }
+
+ /**
+ * Responsible for constructing a one line script appropriate for a specific target.
+ */
+ public static class ScriptBuilder {
+
+ private static final Joiner SCRIPT_JOINER = Joiner.on(" ");
+
+ /**
+ * Escape any special shell characters so that the target shell treats them as literal
+ * characters.
+ *
+ * <p>e,g, an escaped space will not be treated as a token separator, an escaped dollar will
+ * not cause shell substitution.
+ */
+ @VisibleForTesting
+ static String escape(String token) {
+ int length = token.length();
+ StringBuilder builder = new StringBuilder(length);
+ for (int i = 0; i < length; ++i) {
+ char c = token.charAt(i);
+ if (Character.isWhitespace(c)
+ || c == '\'' || c == '\"'
+ || c == '|' || c == '&'
+ || c == '<' || c == '>'
+ || c == '$' || c == '!'
+ || c == '(' || c == ')') {
+ builder.append('\\');
+ }
+
+ builder.append(c);
+ }
+
+ return builder.toString();
+ }
+
+ /**
+ * The prefix to insert before the script to produce a command line that will execute the
+ * script within a shell.
+ */
+ private final ImmutableList<String> commandLinePrefix;
+
+ /**
+ * The list of tokens making up the script, they were escaped where necessary before they
+ * were added to the list.
+ */
+ private final List<String> escapedTokens;
+
+ private ScriptBuilder(ImmutableList<String> commandLinePrefix) {
+ this.commandLinePrefix = commandLinePrefix;
+ escapedTokens = new ArrayList<>();
+ }
+
+ /**
+ * Set the working directory in the target shell before running the command.
+ */
+ public ScriptBuilder workingDirectory(File workingDirectory) {
+ escapedTokens.add("cd");
+ escapedTokens.add(escape(workingDirectory.getPath()));
+ escapedTokens.add("&&");
+ return this;
+ }
+
+ /**
+ * Set inline environment variables on the target shell that will affect the command.
+ */
+ public void env(Map<String, String> env) {
+ for (Map.Entry<String, String> entry : env.entrySet()) {
+ String name = entry.getKey();
+ String value = entry.getValue();
+ escapedTokens.add(name + "=" + escape(value));
+ }
+ }
+
+ /**
+ * Add tokens to the script.
+ *
+ * <p>Each token is escaped before adding it to the list. This method is suitable for adding
+ * the command line name and arguments.
+ */
+ public ScriptBuilder tokens(List<String> tokens) {
+ for (String token : tokens) {
+ escapedTokens.add(escape(token));
+ }
+ return this;
+ }
+
+ /**
+ * Add tokens to the script.
+ *
+ * <p>Each token is escaped before adding it to the list. This method is suitable for adding
+ * the command line name and arguments.
+ */
+ public ScriptBuilder tokens(String... tokens) {
+ for (String token : tokens) {
+ escapedTokens.add(escape(token));
+ }
+ return this;
+ }
+
+ /**
+ * Construct a command line to execute the script in the target shell.
+ */
+ public List<String> commandLine() {
+ // Group the tokens into a single String argument. This is necessary for running in
+ // a local shell where the first argument after the -c option is the script and the
+ // remainder are treated as arguments to the script. This has no effect on either
+ // adb or ssh shells as they both concatenate all their arguments into one single
+ // string before parsing.
+ String grouped = SCRIPT_JOINER.join(escapedTokens);
+ return new ImmutableList.Builder<String>()
+ .addAll(commandLinePrefix)
+ .add(grouped)
+ .build();
+ }
+ }
}
diff --git a/src/vogar/android/AdbTarget.java b/src/vogar/android/AdbTarget.java
index edc7481..48473fd 100644
--- a/src/vogar/android/AdbTarget.java
+++ b/src/vogar/android/AdbTarget.java
@@ -31,6 +31,9 @@
public final class AdbTarget extends Target {
+ private static final ImmutableList<String> TARGET_PROCESS_PREFIX =
+ ImmutableList.of("adb", "shell");
+
private final Log log;
private final DeviceFilesystem deviceFilesystem;
@@ -49,8 +52,8 @@
return new File("/data/local/tmp/vogar");
}
- @Override public List<String> targetProcessPrefix() {
- return ImmutableList.of("adb", "shell");
+ @Override protected ImmutableList<String> targetProcessPrefix() {
+ return TARGET_PROCESS_PREFIX;
}
@Override public void await(File directory) {
diff --git a/src/vogar/android/DeviceRuntime.java b/src/vogar/android/DeviceRuntime.java
index 5cac207..14e7841 100644
--- a/src/vogar/android/DeviceRuntime.java
+++ b/src/vogar/android/DeviceRuntime.java
@@ -84,16 +84,13 @@
@Override public VmCommandBuilder newVmCommandBuilder(Action action, File workingDirectory) {
List<String> vmCommand = new ArrayList<String>();
- vmCommand.addAll(run.target.targetProcessPrefix());
- vmCommand.add("cd");
- vmCommand.add(workingDirectory.getAbsolutePath());
- vmCommand.add("&&");
- vmCommand.add(run.getAndroidData());
Iterables.addAll(vmCommand, run.invokeWith());
vmCommand.add(run.vmCommand);
// If you edit this, see also HostRuntime...
VmCommandBuilder vmCommandBuilder = new VmCommandBuilder(run.log)
+ .env("ANDROID_DATA", run.getAndroidDataPath())
+ .workingDirectory(workingDirectory)
.vmCommand(vmCommand)
.vmArgs("-Duser.home=" + run.deviceUserHome)
.maxLength(1024);
diff --git a/src/vogar/android/HostRuntime.java b/src/vogar/android/HostRuntime.java
index 91ceae1..f5bb8ec 100644
--- a/src/vogar/android/HostRuntime.java
+++ b/src/vogar/android/HostRuntime.java
@@ -111,22 +111,21 @@
}
List<String> vmCommand = new ArrayList<String>();
- vmCommand.addAll(run.target.targetProcessPrefix());
- vmCommand.add("ANDROID_PRINTF_LOG=tag");
- vmCommand.add("ANDROID_LOG_TAGS=*:i");
- vmCommand.add("ANDROID_DATA=" + dalvikCache().getParent());
- vmCommand.add("ANDROID_ROOT=" + hostOut);
- vmCommand.add("LD_LIBRARY_PATH=" + libDir);
- vmCommand.add("DYLD_LIBRARY_PATH=" + libDir);
- // This is needed on the host so that the linker loads core.oat at the necessary address.
- vmCommand.add("LD_USE_LOAD_BIAS=1");
Iterables.addAll(vmCommand, run.invokeWith());
vmCommand.add(hostOut + "/bin/" + run.vmCommand);
- VmCommandBuilder builder = new VmCommandBuilder(run.log);
-
// If you edit this, see also DeviceRuntime...
- builder.vmCommand(vmCommand)
+ VmCommandBuilder builder = new VmCommandBuilder(run.log)
+ .env("ANDROID_PRINTF_LOG", "tag")
+ .env("ANDROID_LOG_TAGS", "*:i")
+ .env("ANDROID_DATA", dalvikCache().getParent())
+ .env("ANDROID_ROOT", hostOut)
+ .env("LD_LIBRARY_PATH", libDir)
+ .env("DYLD_LIBRARY_PATH", libDir)
+ // This is needed on the host so that the linker loads core.oat at the necessary
+ // address.
+ .env("LD_USE_LOAD_BIAS", "1")
+ .vmCommand(vmCommand)
.vmArgs("-Xbootclasspath:" + bootClasspath.toString())
.vmArgs("-Duser.language=en")
.vmArgs("-Duser.region=US");
diff --git a/src/vogar/commands/Command.java b/src/vogar/commands/Command.java
index a1b9b8a..bd347a1 100644
--- a/src/vogar/commands/Command.java
+++ b/src/vogar/commands/Command.java
@@ -17,6 +17,7 @@
package vogar.commands;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
@@ -55,7 +56,7 @@
public Command(Log log, String... args) {
this.log = log;
- this.args = processArgs(Arrays.asList(args));
+ this.args = ImmutableList.copyOf(args);
this.env = Collections.emptyMap();
this.permitNonZeroExitStatus = false;
this.tee = null;
@@ -63,7 +64,7 @@
private Command(Builder builder) {
this.log = builder.log;
- this.args = processArgs(builder.args);
+ this.args = ImmutableList.copyOf(builder.args);
this.env = builder.env;
this.permitNonZeroExitStatus = builder.permitNonZeroExitStatus;
this.tee = builder.tee;
@@ -76,27 +77,6 @@
}
}
- private static List<String> processArgs(List<String> args) {
- // Translate ["sh", "-c", "ls", "/tmp"] into ["sh", "-c", "ls /tmp"].
- // This is needed for host execution.
- ArrayList<String> actual = new ArrayList<String>();
- int i = 0;
- while (i < args.size()) {
- String arg = args.get(i++);
- actual.add(arg);
- if (arg.equals("-c")) break;
- }
- if (i < args.size()) {
- String cmd = "";
- for (; i < args.size(); ++i) {
- cmd += args.get(i) + " ";
- }
- actual.add(cmd);
- }
-
- return actual;
- }
-
public void start() throws IOException {
if (isStarted()) {
throw new IllegalStateException("Already started!");
diff --git a/src/vogar/commands/VmCommandBuilder.java b/src/vogar/commands/VmCommandBuilder.java
index e82befd..c532984 100644
--- a/src/vogar/commands/VmCommandBuilder.java
+++ b/src/vogar/commands/VmCommandBuilder.java
@@ -27,6 +27,9 @@
import java.util.Map;
import vogar.Classpath;
import vogar.Log;
+import vogar.Target;
+
+import static com.google.common.base.Preconditions.checkNotNull;
/**
* Builds a virtual machine command.
@@ -45,6 +48,7 @@
private List<String> vmCommand = Collections.singletonList("java");
private List<String> vmArgs = new ArrayList<String>();
private List<String> args = new ArrayList<String>();
+ private File workingDirectory;
private Map<String, String> env = new LinkedHashMap<String, String>();
public VmCommandBuilder(Log log) {
@@ -56,6 +60,14 @@
return this;
}
+ /**
+ * Set the working directory of the target process.
+ */
+ public VmCommandBuilder workingDirectory(File workingDirectory) {
+ this.workingDirectory = workingDirectory;
+ return this;
+ }
+
public VmCommandBuilder temp(File temp) {
this.temp = temp;
return this;
@@ -85,8 +97,11 @@
return this;
}
+ /**
+ * Add a setting for an environment variable in the target process.
+ */
public VmCommandBuilder env(String key, String value) {
- this.env.put(key, value);
+ env.put(key, value);
return this;
}
@@ -128,42 +143,50 @@
return this;
}
- public Command build() {
- Command.Builder builder = new Command.Builder(log);
+ public Command build(Target target) {
+ // Make sure that the main class to run has been specified.
+ checkNotNull(mainClass, "mainClass may not be null");
- for (Map.Entry<String, String> entry : env.entrySet()) {
- builder.env(entry.getKey(), entry.getValue());
+ Target.ScriptBuilder builder = target.newScriptBuilder();
+
+ if (workingDirectory != null) {
+ builder.workingDirectory(workingDirectory);
}
- builder.args(vmCommand);
+ builder.env(env);
+
+ builder.tokens(vmCommand);
if (classpathViaProperty) {
- builder.args("-Djava.class.path=" + classpath);
+ builder.tokens("-Djava.class.path=" + classpath);
} else {
- builder.args("-classpath", classpath.toString());
+ builder.tokens("-classpath", classpath.toString());
}
// Only output this if there's something on the boot classpath,
// otherwise dalvikvm gets upset.
if (!bootClasspath.isEmpty()) {
- builder.args("-Xbootclasspath/a:" + bootClasspath);
+ builder.tokens("-Xbootclasspath/a:" + bootClasspath);
}
if (userDir != null) {
- builder.args("-Duser.dir=" + userDir);
+ builder.tokens("-Duser.dir=" + userDir);
}
if (temp != null) {
- builder.args("-Djava.io.tmpdir=" + temp);
+ builder.tokens("-Djava.io.tmpdir=" + temp);
}
if (debugPort != null) {
- builder.args("-Xrunjdwp:transport=dt_socket,address="
+ builder.tokens("-Xrunjdwp:transport=dt_socket,address="
+ debugPort + ",server=y,suspend=y");
}
- builder.args(vmArgs);
- builder.args(mainClass);
- builder.args(args);
- builder.tee(output);
- builder.maxLength(maxLength);
- return builder.build();
+ builder.tokens(vmArgs);
+ builder.tokens(mainClass);
+ builder.tokens(args);
+
+ return new Command.Builder(log)
+ .args(builder.commandLine())
+ .tee(output)
+ .maxLength(maxLength)
+ .build();
}
}
diff --git a/src/vogar/tasks/RunActionTask.java b/src/vogar/tasks/RunActionTask.java
index c296c72..91e46d8 100644
--- a/src/vogar/tasks/RunActionTask.java
+++ b/src/vogar/tasks/RunActionTask.java
@@ -157,7 +157,7 @@
.vmArgs(run.additionalVmArgs)
.mainClass(TestRunner.class.getName())
.args(run.targetArgs)
- .build();
+ .build(run.target);
}
/**
diff --git a/test/vogar/AllTests.java b/test/vogar/AllTests.java
index 930554b..6ff9fd2 100644
--- a/test/vogar/AllTests.java
+++ b/test/vogar/AllTests.java
@@ -20,7 +20,8 @@
DeviceRuntimeAdbTargetTest.class,
DeviceRuntimeSshTargetTest.class,
HostRuntimeLocalTargetTest.class,
- JUnitRunnerTest.class
+ JUnitRunnerTest.class,
+ ScriptBuilderEscapingTest.class
})
@RunWith(Suite.class)
public class AllTests {
diff --git a/test/vogar/ScriptBuilderEscapingTest.java b/test/vogar/ScriptBuilderEscapingTest.java
new file mode 100644
index 0000000..6965251
--- /dev/null
+++ b/test/vogar/ScriptBuilderEscapingTest.java
@@ -0,0 +1,58 @@
+package vogar;
+
+import junit.framework.Test;
+import junit.framework.TestCase;
+import junit.framework.TestSuite;
+import vogar.Target.ScriptBuilder;
+
+/**
+ * Tests the {@link ScriptBuilder#escape(String)} method.
+ */
+public class ScriptBuilderEscapingTest {
+
+ public static Test suite() {
+ TestSuite suite = new TestSuite(ScriptBuilderEscapingTest.class.getName());
+ char[] chars = " '\"<>&|$".toCharArray();
+ for (char c : chars) {
+ suite.addTest(new SingleCharacterEscapeTest(c));
+ }
+ suite.addTest(new MixedCharacterEscapeTest());
+ return suite;
+ }
+
+ private static class SingleCharacterEscapeTest extends TestCase {
+
+ private final String uc;
+ private final String qc;
+
+ public SingleCharacterEscapeTest(char c) {
+ this.uc = Character.toString(c);
+ this.qc = "\\" + c;
+ setName("Escape '" + uc + "' as '" + qc + "'");
+ }
+
+ @Override
+ protected void runTest() throws Throwable {
+ assertEquals(qc, ScriptBuilder.escape(uc));
+ assertEquals("a" + qc, ScriptBuilder.escape("a" + uc));
+ assertEquals(qc + "b", ScriptBuilder.escape(uc + "b"));
+ assertEquals("a" + qc + "b", ScriptBuilder.escape("a" + uc + "b"));
+ assertEquals(qc + "a" + qc + qc + qc + "b" + qc,
+ ScriptBuilder.escape(uc + "a" + uc + uc + uc + "b" + uc));
+
+ }
+ }
+
+ private static class MixedCharacterEscapeTest extends TestCase {
+
+ public MixedCharacterEscapeTest() {
+ super("mixed character escape test");
+ }
+
+ @Override
+ protected void runTest() throws Throwable {
+ assertEquals("\\ \\'\\\"\\<\\>\\&\\|\\$",
+ ScriptBuilder.escape(" '\"<>&|$"));
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/vogar/android/DeviceRuntimeAdbTargetTest.java b/test/vogar/android/DeviceRuntimeAdbTargetTest.java
index 9fffe43..c516113 100644
--- a/test/vogar/android/DeviceRuntimeAdbTargetTest.java
+++ b/test/vogar/android/DeviceRuntimeAdbTargetTest.java
@@ -60,23 +60,25 @@
classpath.addAll(new File("classes"));
VmCommandBuilder builder = newVmCommandBuilder(deviceRuntime)
.classpath(classpath)
- .mainClass("mainclass");
- Command command = builder.build();
+ .mainClass("mainclass")
+ .args("-x", "a b");
+ Command command = builder.build(run.target);
List<String> args = command.getArgs();
assertEquals(Arrays.asList(
- "adb", "shell",
- "cd", "/work",
- "&&",
- "ANDROID_DATA=runner",
- "dalvikvm32",
- "-classpath",
- "classes",
- "-Duser.home=runner/dir/user.home",
- "-Duser.name=fred",
- "-Duser.language=en",
- "-Duser.region=US",
- "-Xcheck:jni",
- "-Xjnigreflimit:2000",
- "mainclass"), args);
+ "adb", "shell", ""
+ + "cd /work"
+ + " &&"
+ + " ANDROID_DATA=runner"
+ + " dalvikvm32"
+ + " -classpath"
+ + " classes"
+ + " -Duser.home=runner/dir/user.home"
+ + " -Duser.name=fred"
+ + " -Duser.language=en"
+ + " -Duser.region=US"
+ + " -Xcheck:jni"
+ + " -Xjnigreflimit:2000"
+ + " mainclass"
+ + " -x a\\ b"), args);
}
}
diff --git a/test/vogar/android/DeviceRuntimeSshTargetTest.java b/test/vogar/android/DeviceRuntimeSshTargetTest.java
index b0ef77a..0878d8d 100644
--- a/test/vogar/android/DeviceRuntimeSshTargetTest.java
+++ b/test/vogar/android/DeviceRuntimeSshTargetTest.java
@@ -52,23 +52,25 @@
VmCommandBuilder builder = newVmCommandBuilder(deviceRuntime)
.classpath(classpath)
- .mainClass("mainclass");
- Command command = builder.build();
+ .mainClass("mainclass")
+ .args("-x", "a b");
+ Command command = builder.build(run.target);
List<String> args = command.getArgs();
assertEquals(Arrays.asList(
- "ssh", "-p", "99", "host", "-C",
- "cd", "/work",
- "&&",
- "ANDROID_DATA=runner",
- "dalvikvm32",
- "-classpath",
- "classes",
- "-Duser.home=runner/dir/user.home",
- "-Duser.name=fred",
- "-Duser.language=en",
- "-Duser.region=US",
- "-Xcheck:jni",
- "-Xjnigreflimit:2000",
- "mainclass"), args);
+ "ssh", "-p", "99", "host", "-C", ""
+ + "cd /work"
+ + " &&"
+ + " ANDROID_DATA=runner"
+ + " dalvikvm32"
+ + " -classpath"
+ + " classes"
+ + " -Duser.home=runner/dir/user.home"
+ + " -Duser.name=fred"
+ + " -Duser.language=en"
+ + " -Duser.region=US"
+ + " -Xcheck:jni"
+ + " -Xjnigreflimit:2000"
+ + " mainclass"
+ + " -x a\\ b"), args);
}
}
diff --git a/test/vogar/android/HostRuntimeLocalTargetTest.java b/test/vogar/android/HostRuntimeLocalTargetTest.java
index cad4392..5b9b659 100644
--- a/test/vogar/android/HostRuntimeLocalTargetTest.java
+++ b/test/vogar/android/HostRuntimeLocalTargetTest.java
@@ -55,8 +55,9 @@
classpath.addAll(new File("classes"));
VmCommandBuilder builder = newVmCommandBuilder(hostRuntime)
.classpath(classpath)
- .mainClass("mainclass");
- Command command = builder.build();
+ .mainClass("mainclass")
+ .args("-x", "a b");
+ Command command = builder.build(run.target);
List<String> args = command.getArgs();
assertEquals(Arrays.asList(
"sh", "-c", ""
@@ -78,6 +79,7 @@
+ " -Duser.region=US"
+ " -Xcheck:jni"
+ " -Xjnigreflimit:2000"
- + " mainclass "), args);
+ + " mainclass"
+ + " -x a\\ b"), args);
}
}