Skip to content

Commit

Permalink
Adding a LocalSocketProtocol for the ShellExecutor to talk to the She…
Browse files Browse the repository at this point in the history
…llMain.

PiperOrigin-RevId: 681170535
  • Loading branch information
copybara-androidxtest committed Oct 10, 2024
1 parent d81f250 commit c10d8cb
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 0 deletions.
2 changes: 2 additions & 0 deletions services/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

**New Features**

* LocalSocketProtocol: a replacement for SpeakEasy.

**Breaking Changes**

**API Changes**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ kt_android_library(
],
)

proto_library(
name = "local_socket_protocol_pb",
srcs = ["local_socket_protocol.proto"],
)

java_lite_proto_library(
name = "local_socket_protocol_pb_java_proto_lite",
visibility = [
"//services/shellexecutor/javatests/androidx/test/services/shellexecutor:__subpackages__",
],
deps = [":local_socket_protocol_pb"],
)

kt_android_library(
name = "local_socket_protocol",
srcs = ["LocalSocketProtocol.kt"],
visibility = [
"//services/shellexecutor/javatests/androidx/test/services/shellexecutor:__subpackages__",
],
deps = [
":local_socket_protocol_pb_java_proto_lite",
"@com_google_protobuf//:protobuf_javalite",
"@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_core",
],
)

kt_android_library(
name = "exec_server",
srcs = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* 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 androidx.test.services.shellexecutor

import android.net.LocalSocket
import android.net.LocalSocketAddress
import android.util.Log
import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandRequest
import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandResponse
import com.google.protobuf.ByteString
import java.io.IOException
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.time.Duration

/**
* Protocol for ShellCommandLocalSocketClient to talk to ShellCommandLocalSocketExecutorServer.
*
* Since androidx.test.services already includes the protobuf runtime, we aren't paying much extra
* for adding some more protos to ship back and forth, which is vastly easier to deal with than
* PersistableBundles (which don't even support ByteArray types).
*
* A conversation consists of a single RunCommandRequest from the client followed by a stream of
* RunCommandResponses from the server; the final response has an exit code.
*/
object LocalSocketProtocol {
/** Composes a RunCommandRequest and sends it over the LocalSocket. */
fun LocalSocket.sendRequest(
secret: String,
argv: List<String>,
env: Map<String, String>? = null,
timeout: Duration,
) {
val builder = RunCommandRequest.newBuilder()
builder.setSecret(secret)
builder.addAllArgv(argv)
env?.forEach { (k, v) -> builder.putEnvironment(k, v) }
builder.setTimeoutMs(timeout.inWholeMilliseconds)
builder.build().writeDelimitedTo(outputStream)
}

/** Reads a RunCommandRequest from the LocalSocket. */
fun LocalSocket.readRequest(): RunCommandRequest {
return RunCommandRequest.parseDelimitedFrom(inputStream)!!
}

/** Composes a RunCommandResponse and sends it over the LocalSocket. */
fun LocalSocket.sendResponse(
buffer: ByteArray? = null,
size: Int = 0,
exitCode: Int? = null,
): Boolean {
val builder = RunCommandResponse.newBuilder()
buffer?.let {
val bufferSize = if (size > 0) size else it.size
builder.buffer = ByteString.copyFrom(it, 0, bufferSize)
}
// Since we're currently stuck on a version of protobuf where we don't have hasExitCode(), we
// use a magic value to indicate that exitCode is not set. When we upgrade to a newer version
// of protobuf, we can obsolete this.
if (exitCode != null) {
builder.exitCode = exitCode
} else {
builder.exitCode = HAS_NOT_EXITED
}

try {
builder.build().writeDelimitedTo(outputStream)
} catch (x: IOException) {
// Sadly, the only way to discover that the client cut the connection is an exception that
// can only be distinguished by its text.
if (x.message.equals("Broken pipe")) {
Log.i(TAG, "LocalSocket stream closed early")
} else {
Log.w(TAG, "LocalSocket write failed", x)
}
return false
}
return true
}

/** Reads a RunCommandResponse from the LocalSocket. */
fun LocalSocket.readResponse(): RunCommandResponse? {
return RunCommandResponse.parseDelimitedFrom(inputStream)
}

/**
* Is this the end of the stream?
*
* Once we upgrade to a newer version of protobuf, we can switch to hasExitCode().
*/
fun RunCommandResponse.hasExited() = exitCode != HAS_NOT_EXITED

/**
* Builds a binder key, given the server address and secret. Binder keys should be opaque outside
* this directory.
*
* The address can contain spaces, and since it gets passed through a command line, we need to
* encode it so it doesn't get split by argv. java.net.URLEncoder is conveniently available on all
* SDK versions.
*/
@JvmStatic
fun LocalSocketAddress.asBinderKey(secret: String) = buildString {
append(":")
append(URLEncoder.encode(name, "UTF-8")) // Will convert any : to %3A
append(":")
append(URLEncoder.encode(secret, "UTF-8"))
append(":")
}

/** Extracts the address from a binder key. */
@JvmStatic
fun addressFromBinderKey(binderKey: String) =
LocalSocketAddress(URLDecoder.decode(binderKey.split(":")[1], "UTF-8"))

/** Extracts the secret from a binder key. */
@JvmStatic
fun secretFromBinderKey(binderKey: String) = URLDecoder.decode(binderKey.split(":")[2], "UTF-8")

/** Is this a valid binder key? */
@JvmStatic
fun isBinderKey(maybeKey: String) =
maybeKey.startsWith(':') && maybeKey.endsWith(':') && maybeKey.split(":").size == 4

const val TAG = "LocalSocketProtocol"
private const val HAS_NOT_EXITED = 0xCA7F00D
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// Copyright (C) 2024 The Android Open Source Project
//
// 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.

edition = "2023";

// syntax = "proto3"; // Maven on github doesn't like 'edition = "2023"'

package androidx.test.services.storage;

option java_package = "androidx.test.services.shellexecutor";
option java_outer_classname = 'LocalSocketProtocolProto';

// Message sent from client to server to start a process.
message RunCommandRequest {
string secret = 1;
repeated string argv = 2;
map<string, string> environment = 3;
int64 timeout_ms = 4;
}

// Multiple responses can be streamed back to the client. The one that has an
// exit code indicates the end of the stream.
message RunCommandResponse {
bytes buffer = 1;
int32 exit_code = 2;
}

0 comments on commit c10d8cb

Please sign in to comment.