FileObserverProtocol.kt

/*
 * Copyright (C) 2023 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 java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.util.UUID

/**
 * The protocol for communicating by FileObserver is:
 * 1. The server creates the server directory in /data/local/tmp.
 * 2. The client creates [UUID].request in the server directory.
 * 3. The server reads and deletes [UUID].request, then writes [UUID].response.
 * 4. The client reads and deletes [UUID].response.
 *
 * The underlying communication is handled by inotify, which only generates events for the
 * directories it is explicitly watching. (The FileObserver documentation makes it sound like it can
 * pick things up in subdirectories; this is erroneous.)
 *
 * The underlying directory and file are set world-readable and -writable so the client can write
 * the request and read the response. Because this only works when someone is already running
 * FileObserverShellMain, there is very little threat here; if someone is able to put a program onto
 * your test device that can watch /data/local/tmp for the appearance of the exchange directory, you
 * have bigger problems than whatever it's going to do with root privileges.
 */
@Suppress("SetWorldReadable", "SetWorldWritable")
object FileObserverProtocol {
  const val REQUEST = "request"
  const val RESPONSE = "response"

  /** Creates the exchange directory with appropriate permissions. */
  fun createExchangeDir(commonDir: File): File {
    val exchangeDir = File.createTempFile("androidx", ".tmp", commonDir)
    exchangeDir.delete()
    exchangeDir.mkdir()
    exchangeDir.setReadable(/* readable= */ true, /* ownerOnly= */ false)
    exchangeDir.setWritable(/* writable= */ true, /* ownerOnly= */ false)
    exchangeDir.setExecutable(/* executable= */ true, /* ownerOnly= */ false)
    return exchangeDir
  }

  /**
   * Writes a request file to the exchange directory.
   *
   * @return the location for the response file
   */
  fun writeRequestFile(exchangeDir: File, message: Messages.Command): File {
    val stem = UUID.randomUUID().toString()
    val request = File(exchangeDir, "${stem}.$REQUEST")
    request.outputStream().use {
      request.setReadable(/* readable= */ true, /* ownerOnly= */ false)
      request.setWritable(/* writable= */ true, /* ownerOnly= */ false)
      message.writeTo(it)
    }
    return File(exchangeDir, "${stem}.response")
  }

  fun isRequestFile(file: File) = file.name.endsWith(".$REQUEST")

  fun calculateResponseFile(requestFile: File) =
    File(requestFile.parentFile, "${requestFile.name.split(".").first()}.$RESPONSE")

  /** Reads and deletes the request file */
  fun readRequestFile(request: File): Messages.Command {
    val command: Messages.Command
    request.inputStream().use { command = Messages.Command.readFrom(it) }
    request.delete()
    return command
  }

  /** Writes the response file */
  fun writeResponseFile(path: File, result: Messages.CommandResult) {
    path.outputStream().use {
      path.setReadable(/* readable= */ true, /* ownerOnly= */ false)
      path.setWritable(/* writable= */ true, /* ownerOnly= */ false)
      result.writeTo(it)
    }
  }

  /** Reads and deletes the response file. */
  fun readResponseFile(response: File): Messages.CommandResult {
    try {
      val result: Messages.CommandResult
      response.inputStream().use { result = Messages.CommandResult.readFrom(it) }
      response.delete()
      return result
    } catch (x: IOException) {
      return Messages.CommandResult(
        resultType = Messages.ResultType.CLIENT_ERROR,
        stderr = x.toByteArray()
      )
    }
  }
}

/**
 * Writes an exception stack trace to a ByteArray as UTF-8, to make them easy to pass through
 * Messages.CommandResult.
 */
public fun Exception.toByteArray(): ByteArray {
  val bos = ByteArrayOutputStream()
  val pw = PrintWriter(OutputStreamWriter(bos, Charsets.UTF_8))
  printStackTrace(pw)
  pw.close()
  return bos.toByteArray()
}