From c10d8cb28c93b9d81329887cbb5ab6bb4e71b070 Mon Sep 17 00:00:00 2001 From: AndroidX Test Team Date: Tue, 1 Oct 2024 14:40:16 -0700 Subject: [PATCH] Adding a LocalSocketProtocol for the ShellExecutor to talk to the ShellMain. PiperOrigin-RevId: 681170535 --- services/CHANGELOG.md | 2 + .../test/services/shellexecutor/BUILD | 26 ++++ .../shellexecutor/LocalSocketProtocol.kt | 141 ++++++++++++++++++ .../shellexecutor/local_socket_protocol.proto | 38 +++++ 4 files changed, 207 insertions(+) create mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketProtocol.kt create mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/local_socket_protocol.proto diff --git a/services/CHANGELOG.md b/services/CHANGELOG.md index 9d62f882d..b0406bc36 100644 --- a/services/CHANGELOG.md +++ b/services/CHANGELOG.md @@ -11,6 +11,8 @@ **New Features** +* LocalSocketProtocol: a replacement for SpeakEasy. + **Breaking Changes** **API Changes** diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD index c1257b98d..0d5eb54c9 100644 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD @@ -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 = [ diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketProtocol.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketProtocol.kt new file mode 100644 index 000000000..c4ad9524b --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketProtocol.kt @@ -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, + env: Map? = 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 +} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/local_socket_protocol.proto b/services/shellexecutor/java/androidx/test/services/shellexecutor/local_socket_protocol.proto new file mode 100644 index 000000000..7d0bd4534 --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/local_socket_protocol.proto @@ -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 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; +}