Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions android/plugchoice/src/main/java/com/plugchoice/internal/Http1.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.InputStream
import java.net.ProtocolException
import java.util.Base64
import java.util.Locale

/**
Expand All @@ -31,13 +32,34 @@ internal object Http1 {
/** The device closes the connection after this response. */
val closesConnection: Boolean,
) {
fun toJson(): JSONObject {
fun toJson(responseBody: ResponseBody = ResponseBody.TEXT): JSONObject {
val headerObject = JSONObject()
for ((name, value) in headers) headerObject.put(name, value)
return JSONObject()
.put("status", status)
.put("headers", headerObject)
.put("body", String(body, Charsets.UTF_8))
.put("body", responseBody.encode(body))
}
}

/** How a response body reaches the page (PROTOCOL §9.2): as UTF-8 text, or as base64 of its bytes, for a binary body such as an archive. */
enum class ResponseBody(val param: String) {
TEXT("text"),
BASE64("base64");

fun encode(body: ByteArray): String = when (this) {
TEXT -> String(body, Charsets.UTF_8)
BASE64 -> Base64.getEncoder().encodeToString(body)
}

companion object {
/** `responseBody` (absent: `text`). */
fun parse(params: JSONObject): ResponseBody {
if (!params.has("responseBody") || params.isNull("responseBody")) return TEXT
val value = params.opt("responseBody")
return entries.firstOrNull { it.param == value }
?: throw BridgeException.invalidParams("responseBody must be text or base64")
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ internal class HttpSessions(
val headers: Map<String, String>,
val body: String?,
val timeoutMs: Long,
val responseBody: Http1.ResponseBody = Http1.ResponseBody.TEXT,
)

private val lock = Any()
Expand Down Expand Up @@ -188,7 +189,7 @@ internal class HttpSessions(
// session gone.
closeConnection()
}
answer(pending, Result.success(response.toJson()))
answer(pending, Result.success(response.toJson(request.responseBody)))
} catch (e: IOException) {
closeConnection()
val error = when {
Expand Down Expand Up @@ -306,7 +307,7 @@ internal class HttpSessions(
val body = params.optStringOrNull("body")
if (method == "GET" && !body.isNullOrEmpty()) throw BridgeException.invalidParams("a GET request cannot have a body")
val timeoutMs = params.requireTimeoutMs("timeoutMs")
return sessionId to Request(method, path, headers, if (method == "GET") null else body, timeoutMs)
return sessionId to Request(method, path, headers, if (method == "GET") null else body, timeoutMs, Http1.ResponseBody.parse(params))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ internal class LocalNetworkClient(
val headers = buildHeaders(params.optHeaders())
val body = params.optStringOrNull("body")
val timeoutMs = params.requireTimeoutMs("timeoutMs")
val responseBody = Http1.ResponseBody.parse(params)
val evaluator = Trust.parse(params.opt("trust"))?.takeIf { url.isHttps }?.let { TrustEvaluator(it, url.host) }

val request = Request.Builder()
Expand All @@ -121,7 +122,7 @@ internal class LocalNetworkClient(
throw BridgeException.invalidParams("requestId $requestId is already in flight")
}
try {
return call.awaitResult()
return call.awaitResult(responseBody)
} catch (e: IOException) {
throw when {
cancelledByPage.contains(requestId) ->
Expand Down Expand Up @@ -234,7 +235,7 @@ internal class LocalNetworkClient(
return body.toByteArray(Charsets.UTF_8).toRequestBody(headers["Content-Type"]?.toMediaTypeOrNull())
}

private suspend fun Call.awaitResult(): JSONObject = suspendCancellableCoroutine { continuation ->
private suspend fun Call.awaitResult(responseBody: Http1.ResponseBody): JSONObject = suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { cancel() }
enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Expand All @@ -243,7 +244,7 @@ internal class LocalNetworkClient(

override fun onResponse(call: Call, response: Response) {
val result = try {
response.use { toResult(it) }
response.use { toResult(it, responseBody) }
} catch (e: IOException) {
continuation.resumeWithException(e)
return
Expand All @@ -253,7 +254,7 @@ internal class LocalNetworkClient(
})
}

private fun toResult(response: Response): JSONObject {
private fun toResult(response: Response, responseBody: Http1.ResponseBody): JSONObject {
// Lower-cased names; repeated headers (Set-Cookie in particular) joined with ", ".
val grouped = LinkedHashMap<String, MutableList<String>>()
for (i in 0 until response.headers.size) {
Expand All @@ -266,7 +267,7 @@ internal class LocalNetworkClient(
return JSONObject()
.put("status", response.code)
.put("headers", headers)
.put("body", String(bytes, Charsets.UTF_8))
.put("body", responseBody.encode(bytes))
}

/** One `ws.*` socket. Events go out in OkHttp's order; `ws.close` is always the last one. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ class Http1Test {
}
}

@Test
fun `a binary body as base64, and as text by default`() {
// A gzip header: not UTF-8, so text would lose it.
val gzip = byteArrayOf(0x1F, 0x8B.toByte(), 0x08, 0x00, 0xFF.toByte())
val response = Http1.readResponse(ByteArrayInputStream("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n".toByteArray() + gzip))
assertEquals("H4sIAP8=", response.toJson(Http1.ResponseBody.BASE64).getString("body"))
assertTrue(java.util.Base64.getDecoder().decode(response.toJson(Http1.ResponseBody.BASE64).getString("body")).contentEquals(gzip))
assertEquals("ok", read("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok").toJson().getString("body"))
}

@Test
fun `a Content-Length body`() {
val response = read("HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nhello world")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ class HttpSessionsTest {
assertEquals(2500L, request.timeoutMs)
assertNull("an empty body on GET is no body", HttpSessions.parseRequest(requestParams("body" to "")).second.body)
assertEquals(emptyMap<String, String>(), HttpSessions.parseRequest(requestParams().apply { remove("headers") }).second.headers)
// The body comes as text unless asked for as base64.
assertEquals(Http1.ResponseBody.TEXT, HttpSessions.parseRequest(requestParams()).second.responseBody)
assertEquals(Http1.ResponseBody.BASE64, HttpSessions.parseRequest(requestParams("responseBody" to "base64")).second.responseBody)
for (bad in listOf("binary", "BASE64", 1, true)) {
assertCode(ErrorCode.INVALID_PARAMS) { HttpSessions.parseRequest(requestParams("responseBody" to bad)) }
}
}

@Test
Expand Down
6 changes: 4 additions & 2 deletions bridge/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,11 @@ Anything else, a percent-encoded host, and for URLs another scheme answer `forbi

| Method | Params | Result |
|---|---|---|
| `http.request` | `{ requestId: string, url: string, method: "GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE", headers?, body?: string, timeoutMs: number, trust?: Trust }` | `{ status: number, headers: Record<string, string>, body: string }` |
| `http.request` | `{ requestId: string, url: string, method: "GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE", headers?, body?: string, timeoutMs: number, trust?: Trust, responseBody?: "text" \| "base64" }` | `{ status: number, headers: Record<string, string>, body: string }` |
| `http.cancel` | `{ requestId: string }` | `{}`; the request then answers `cancelled` (an unknown id is fine) |

- `url` is `http` or `https`; `trust` applies to `https` only. `method` is case-insensitive.
- `responseBody` (absent: `text`): `text` answers the body decoded as UTF-8, so bytes that aren't UTF-8 are replaced; `base64` answers it as base64 of its bytes, for a binary body such as an archive. Anything else is `invalidParams`.
- `timeoutMs` is a deadline for the whole request.
- A `GET` can't have a body (an empty one is none). A `requestId` already in flight is `invalidParams`.

Expand Down Expand Up @@ -280,13 +281,14 @@ A kept-alive HTTPS connection to one device, for devices that keep their login o
| Method | Params | Result |
|---|---|---|
| `http.session.open` | `{ host: string, port?: number, trust?: Trust, timeoutMs?: number }` | `{ sessionId: string }` |
| `http.session.request` | `{ sessionId: string, method: "GET" \| "POST" \| "PUT", path: string, headers?, body?: string, timeoutMs: number }` | `{ status: number, headers: Record<string, string>, body: string }` |
| `http.session.request` | `{ sessionId: string, method: "GET" \| "POST" \| "PUT", path: string, headers?, body?: string, timeoutMs: number, responseBody?: "text" \| "base64" }` | `{ status: number, headers: Record<string, string>, body: string }` |
| `http.session.close` | `{ sessionId: string }` | `{}` |

- `open` makes the TCP and TLS handshake: `port` defaults to 443; `timeoutMs` defaults to 10 s and is clamped to 500 ms to 30 s (a subnet sweep probes with a short one). A refused or unreachable host fails with `network` as soon as the OS says so.
- **One connection per session**, never shared, pooled or silently replaced. Requests on a session run one at a time, in the order sent.
- A request's `timeoutMs` counts from when the shell receives it, so waiting behind earlier requests counts. A request that times out before it was sent leaves the session alone; once sent, `timeout` ends the session (the device may still answer, out of step). `network` ends it too. A request on an ended session answers `unknownSession`. When the device ends the connection after an answer (`Connection: close`, or a body that runs until the connection closes), that answer comes through and the next request answers `network`.
- `path` starts with `/` and holds printable ASCII without spaces or `#` (a query is fine). The body is sent with an explicit `Content-Length`, never chunked; a `GET` can't have one. The shell writes `Host` and `Content-Length` itself and drops the page's `Host`, `Content-Length`, `Transfer-Encoding` and `Connection`.
- `responseBody` as for `http.request` (§9.2).
- `close` is idempotent; requests still waiting answer `network`.
- At most 64 sessions, opening ones included (`tooManySessions`).
- `tls` only when the certificate didn't verify; any other failed handshake (plain HTTP on that port, for example) is `network`.
Expand Down
16 changes: 14 additions & 2 deletions ios/Sources/PlugchoiceSDK/Bridge/Bridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,10 +425,21 @@ final class Bridge {
headers: headers,
body: method == "GET" ? nil : body,
timeoutMs: timeoutMs,
trust: trust
trust: trust,
responseBody: try responseBody(params)
)
}

/// `responseBody` (absent: `text`): the body as UTF-8 text, or as base64
/// of its bytes (PROTOCOL §9.2).
static func responseBody(_ params: Params) throws -> HTTP1.ResponseBody {
guard let value = try params.optionalString("responseBody") else { return .text }
guard let encoding = HTTP1.ResponseBody(rawValue: value) else {
throw BridgeError.invalidParams("responseBody must be text or base64")
}
return encoding
}

struct WebSocketOpenRequest {
let socketId: String
let url: URL
Expand Down Expand Up @@ -633,7 +644,8 @@ final class Bridge {
path: path,
headers: headers,
body: method == "GET" ? nil : body,
timeoutMs: try params.timeoutMs()
timeoutMs: try params.timeoutMs(),
responseBody: try responseBody(params)
)
return (sessionId, request)
}
Expand Down
18 changes: 16 additions & 2 deletions ios/Sources/PlugchoiceSDK/Network/HTTP1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,22 @@ enum HTTP1 {
/// The device closes the connection after this response.
let closesConnection: Bool

var json: JSONObject {
["status": status, "headers": headers, "body": String(decoding: body, as: UTF8.self)]
func json(_ encoding: ResponseBody) -> JSONObject {
["status": status, "headers": headers, "body": encoding.encode(body)]
}
}

/// How a response body reaches the page (PROTOCOL §9.2): as UTF-8 text,
/// or as base64 of its bytes, for a binary body such as an archive.
enum ResponseBody: String {
case text
case base64

func encode(_ body: Data) -> String {
switch self {
case .text: String(decoding: body, as: UTF8.self)
case .base64: body.base64EncodedString()
}
}
}

Expand Down
9 changes: 6 additions & 3 deletions ios/Sources/PlugchoiceSDK/Network/HTTPService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ final class HTTPService {
let timeoutMs: Int
/// Only for `https`; nil: the system's trust.
var trust: Trust?
var responseBody: HTTP1.ResponseBody = .text
}

private enum StopReason {
Expand All @@ -36,13 +37,15 @@ final class HTTPService {
/// The request's own session (a page's trust), invalidated after it.
let ownSession: URLSession?
let delegate: HTTPTaskDelegate
let responseBody: HTTP1.ResponseBody
var stopReason: StopReason?
var timer: DispatchWorkItem?

init(task: URLSessionDataTask, ownSession: URLSession?, delegate: HTTPTaskDelegate) {
init(task: URLSessionDataTask, ownSession: URLSession?, delegate: HTTPTaskDelegate, responseBody: HTTP1.ResponseBody) {
self.task = task
self.ownSession = ownSession
self.delegate = delegate
self.responseBody = responseBody
}
}

Expand Down Expand Up @@ -104,7 +107,7 @@ final class HTTPService {
self?.finish(requestId: requestId, data: data, response: response, error: error, completion: completion)
}
}
let entry = Pending(task: task, ownSession: ownSession, delegate: delegate)
let entry = Pending(task: task, ownSession: ownSession, delegate: delegate, responseBody: request.responseBody)
let timer = DispatchWorkItem { [weak self, weak entry] in
MainActor.assumeIsolated {
guard let self, let entry, self.pending[requestId] === entry else { return }
Expand Down Expand Up @@ -168,7 +171,7 @@ final class HTTPService {
completion(.success([
"status": http.statusCode,
"headers": Self.lowerCasedHeaders(http),
"body": String(decoding: data ?? Data(), as: UTF8.self),
"body": entry.responseBody.encode(data ?? Data()),
]))
}

Expand Down
3 changes: 2 additions & 1 deletion ios/Sources/PlugchoiceSDK/Network/HTTPSessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class HTTPSessions {
let headers: [String: String]
let body: String?
let timeoutMs: Int
var responseBody: HTTP1.ResponseBody = .text
}

typealias Completion = (Result<JSONObject, BridgeError>) -> Void
Expand Down Expand Up @@ -262,7 +263,7 @@ final class HTTPSession {
// request finds the session gone.
closeConnection()
}
finish(pending, .success(response.json))
finish(pending, .success(response.json(pending.request.responseBody)))
startNext()
}

Expand Down
14 changes: 14 additions & 0 deletions ios/Tests/PlugchoiceSDKTests/HTTPSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ final class HTTPSessionTests: XCTestCase {
])
}

func testABinaryBodyAsBase64() throws {
let (id, connection) = try open()
let answers = Answers()
var request = get("/Transactions/download.php")
request.responseBody = .base64
try sessions.request(sessionId: id, request, completion: answers.add)
// A gzip header: not UTF-8, so text would have lost it.
let gzip = Data([0x1F, 0x8B, 0x08, 0x00, 0xFF])
connection.deliverResult(.success((Data("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 5\r\n\r\n".utf8) + gzip, false)))
let result = try XCTUnwrap(answers.results.first).get()
XCTAssertEqual(result["body"] as? String, gzip.base64EncodedString())
XCTAssertEqual(Data(base64Encoded: try XCTUnwrap(result["body"] as? String)), gzip)
}

func testAnEmptyPostStillSendsContentLength() throws {
let (id, connection) = try open()
try sessions.request(
Expand Down
13 changes: 13 additions & 0 deletions ios/Tests/PlugchoiceSDKTests/RequestParamsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ final class RequestParamsTests: XCTestCase {
var noHeaders = requestParams()
noHeaders.removeValue(forKey: "headers")
XCTAssertEqual(try Bridge.sessionRequest(Params(noHeaders)).request.headers, [:])
// The body comes as text unless asked for as base64.
XCTAssertEqual(request.responseBody, .text)
XCTAssertEqual(try Bridge.sessionRequest(Params(requestParams(["responseBody": "base64"]))).request.responseBody, .base64)
XCTAssertEqual(try Bridge.sessionRequest(Params(requestParams(["responseBody": "text"]))).request.responseBody, .text)
for bad: Any in ["binary", "BASE64", 1, true] {
assertCode("invalidParams", "\(bad)") { _ = try Bridge.sessionRequest(Params(requestParams(["responseBody": bad]))) }
}
}

func testSessionRequestRejectsBadParams() {
Expand Down Expand Up @@ -149,6 +156,12 @@ final class RequestParamsTests: XCTestCase {
return params
}

func testHTTPRequestTakesAResponseBody() throws {
XCTAssertEqual(try Bridge.httpRequest(Params(httpParams())).responseBody, .text)
XCTAssertEqual(try Bridge.httpRequest(Params(httpParams(["responseBody": "base64"]))).responseBody, .base64)
assertCode("invalidParams", "binary") { _ = try Bridge.httpRequest(Params(httpParams(["responseBody": "binary"]))) }
}

func testHTTPRequestTakesATrust() throws {
XCTAssertNil(try Bridge.httpRequest(Params(httpParams())).trust)
let request = try Bridge.httpRequest(Params(httpParams(["trust": ["fingerprints": [TrustTests.ecFingerprint]], "method": "post", "body": "{}"])))
Expand Down
Loading