blob: af1bdb289706a7f2cacb4e219e8e0282250ccc1c [file] [log] [blame]
/*
* Copyright 2014-present Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package com.facebook.buck.android.agent;
import com.facebook.buck.android.agent.util.AgentUtil;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Main class for an agent that runs on an Android device to aid app installation.
*
* <p>This does not run as a normal Android app. It is packaged into an APK and installed
* as a convenient way to get it on the device, but it is run from the "adb shell" command line
* using the "dalvikvm" command. Therefore, we do not have an Android Context and therefore
* cannot interact with any system services.
*/
public class AgentMain {
private AgentMain() {}
public static final int CONNECT_TIMEOUT_MS = 5000;
public static final int RECEIVE_TIMEOUT_MS = 20000;
private static final Logger LOG = Logger.getLogger(AgentMain.class.getName());
public static void main(String args[]) throws IOException {
if (args.length == 0) {
LOG.severe("No command specified");
System.exit(1);
}
String command = args[0];
List<String> userArgs =
Collections.unmodifiableList(Arrays.asList(args).subList(1, args.length));
try {
if (command.equals("get-signature")) {
doGetSignature(userArgs);
} else if (command.equals("mkdir-p")) {
doMkdirP(userArgs);
} else if (command.equals("receive-file")) {
doReceiveFile(userArgs);
} else {
throw new IllegalArgumentException("Unknown command: " + command);
}
} catch (Exception e) {
LOG.log(Level.SEVERE, "Command failed", e);
System.exit(1);
}
System.exit(0);
}
/**
* Print the signature of an APK to stdout. The APK path is passed as
* the only command line argument.
*/
private static void doGetSignature(List<String> userArgs) throws IOException {
if (userArgs.size() != 1) {
throw new IllegalArgumentException("usage: get-signature FILE");
}
String packagePath = userArgs.get(0);
System.out.println(AgentUtil.getJarSignature(packagePath));
}
/**
* Roughly equivalent to the shell command "mkdir -p".
*
* Note that some (all?) versions of Android will force restrictive permissions
* on the created directories.
*/
private static void doMkdirP(List<String> userArgs) throws IOException {
if (userArgs.size() != 1) {
throw new IllegalArgumentException("usage: mkdir -p PATH");
}
File path = new File(userArgs.get(0));
boolean success = path.mkdirs();
if (!success) {
throw new IOException("Creating directory failed.");
}
}
/**
* Receive a file over the network and write it to disk.
*
* <p>Arguments are
* <ol>
* <li>The port to listen on.
* <li>The size of the file to receive.
* <li>The path to write it to.
* </ol>
*
* <p>At startup, the agent will print a textual secret key to stdout. It consists of exactly
* {@link AgentUtil#TEXT_SECRET_KEY_SIZE} bytes. The caller must prepend those bytes to the
* file being transmitted, in order to prevent another process from sending a malicious payload.
*/
private static void doReceiveFile(List<String> userArgs) throws IOException {
if (userArgs.size() != 3) {
throw new IllegalArgumentException("usage: receive-file PORT SIZE PATH");
}
int port = Integer.parseInt(userArgs.get(0));
int size = Integer.parseInt(userArgs.get(1));
File path = new File(userArgs.get(2));
// First make sure we can bind to the port.
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
byte[] secretKey = createAndSendSessionKey();
// Open the connection with appropriate timeouts.
serverSocket.setSoTimeout(CONNECT_TIMEOUT_MS);
Socket connectionSocket = serverSocket.accept();
connectionSocket.setSoTimeout(RECEIVE_TIMEOUT_MS);
InputStream input = connectionSocket.getInputStream();
receiveAndValidateSessionKey(secretKey, input);
doRawReceiveFile(path, size, input);
} finally {
if (serverSocket != null) {
serverSocket.close();
}
}
}
private static byte[] createAndSendSessionKey() throws IOException {
// Generate a random key to authenticate the network connection.
// On some devices, I had trouble using SecureRandom in a non-app context
// (it failed to find a native library), so just access urandom directly.
byte[] binaryKey = new byte[AgentUtil.BINARY_SECRET_KEY_SIZE];
InputStream urandom = new BufferedInputStream(new FileInputStream("/dev/urandom"));
try {
int got = urandom.read(binaryKey);
if (got != binaryKey.length) {
throw new RuntimeException("Failed to receive sufficient random bytes for key.");
}
} finally {
urandom.close();
}
StringBuilder keyBuilder = new StringBuilder();
for (byte b : binaryKey) {
keyBuilder.append(String.format((Locale) null, "%02x", b));
}
byte[] secretKey = keyBuilder.toString().getBytes();
if (secretKey.length != AgentUtil.TEXT_SECRET_KEY_SIZE) {
throw new RuntimeException("Bug in secret key formatting");
}
// Send the key over stdout so only the host can read it.
System.out.write(secretKey);
System.out.flush();
return secretKey;
}
private static void receiveAndValidateSessionKey(
byte[] secretKey,
InputStream clientInput) throws IOException {
byte[] receivedKey = new byte[secretKey.length];
int receivedKeySize = clientInput.read(receivedKey);
if (receivedKeySize != receivedKey.length) {
throw new IllegalStateException("Did not receive full-length key.");
}
if (!Arrays.equals(secretKey, receivedKey)) {
throw new IllegalStateException("Received incorrect secret key.");
}
}
private static void doRawReceiveFile(File path, int size, InputStream clientInput)
throws IOException {
// Create a temp file to receive the payload, so we don't need to worry about
// partially-received files. The host takes care of deleting temp files.
File tempfile = File.createTempFile(
AgentUtil.TEMP_PREFIX + path.getName() + "-",
".tmp",
path.getParentFile());
FileOutputStream output = new FileOutputStream(tempfile);
// Keep track of our starting time so we can enforce a timeout on slow but steady uploads.
long receiveStartMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
// Keep track of the total received size to verify the payload.
long totalSize = 0;
try {
final int bufferSize = 128 * 1024;
byte[] buf = new byte[bufferSize];
while (true) {
long currentTimeMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
if (currentTimeMs - receiveStartMs > RECEIVE_TIMEOUT_MS) {
throw new RuntimeException("Receive failed to complete before timeout.");
}
int got = clientInput.read(buf);
if (got == -1) {
break;
}
output.write(buf, 0, got);
totalSize += got;
}
} finally {
output.close();
}
if (totalSize != size) {
throw new RuntimeException("Received only " + totalSize + " of " + size + " bytes.");
}
boolean success = tempfile.renameTo(path);
if (!success) {
throw new RuntimeException("Failed to rename temp file.");
}
}
}