diff --git a/android/plugchoice/src/main/java/com/plugchoice/internal/Http1.kt b/android/plugchoice/src/main/java/com/plugchoice/internal/Http1.kt index 8c5430f..4dce9a4 100644 --- a/android/plugchoice/src/main/java/com/plugchoice/internal/Http1.kt +++ b/android/plugchoice/src/main/java/com/plugchoice/internal/Http1.kt @@ -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 /** @@ -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") + } } } diff --git a/android/plugchoice/src/main/java/com/plugchoice/internal/HttpSessions.kt b/android/plugchoice/src/main/java/com/plugchoice/internal/HttpSessions.kt index 6bbd3aa..e4e2a56 100644 --- a/android/plugchoice/src/main/java/com/plugchoice/internal/HttpSessions.kt +++ b/android/plugchoice/src/main/java/com/plugchoice/internal/HttpSessions.kt @@ -51,6 +51,7 @@ internal class HttpSessions( val headers: Map, val body: String?, val timeoutMs: Long, + val responseBody: Http1.ResponseBody = Http1.ResponseBody.TEXT, ) private val lock = Any() @@ -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 { @@ -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)) } } } diff --git a/android/plugchoice/src/main/java/com/plugchoice/internal/LocalNetworkClient.kt b/android/plugchoice/src/main/java/com/plugchoice/internal/LocalNetworkClient.kt index d9574fe..570e67b 100644 --- a/android/plugchoice/src/main/java/com/plugchoice/internal/LocalNetworkClient.kt +++ b/android/plugchoice/src/main/java/com/plugchoice/internal/LocalNetworkClient.kt @@ -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() @@ -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) -> @@ -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) { @@ -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 @@ -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>() for (i in 0 until response.headers.size) { @@ -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. */ diff --git a/android/plugchoice/src/test/java/com/plugchoice/internal/Http1Test.kt b/android/plugchoice/src/test/java/com/plugchoice/internal/Http1Test.kt index 425a8ae..389e6b5 100644 --- a/android/plugchoice/src/test/java/com/plugchoice/internal/Http1Test.kt +++ b/android/plugchoice/src/test/java/com/plugchoice/internal/Http1Test.kt @@ -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") diff --git a/android/plugchoice/src/test/java/com/plugchoice/internal/HttpSessionsTest.kt b/android/plugchoice/src/test/java/com/plugchoice/internal/HttpSessionsTest.kt index d968637..c7e1efc 100644 --- a/android/plugchoice/src/test/java/com/plugchoice/internal/HttpSessionsTest.kt +++ b/android/plugchoice/src/test/java/com/plugchoice/internal/HttpSessionsTest.kt @@ -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(), 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 diff --git a/bridge/PROTOCOL.md b/bridge/PROTOCOL.md index 32baf49..5c3c408 100644 --- a/bridge/PROTOCOL.md +++ b/bridge/PROTOCOL.md @@ -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, 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, 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`. @@ -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, 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, 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`. diff --git a/ios/Sources/PlugchoiceSDK/Bridge/Bridge.swift b/ios/Sources/PlugchoiceSDK/Bridge/Bridge.swift index 7c60039..3973d67 100644 --- a/ios/Sources/PlugchoiceSDK/Bridge/Bridge.swift +++ b/ios/Sources/PlugchoiceSDK/Bridge/Bridge.swift @@ -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 @@ -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) } diff --git a/ios/Sources/PlugchoiceSDK/Network/HTTP1.swift b/ios/Sources/PlugchoiceSDK/Network/HTTP1.swift index 8787790..6fb48ab 100644 --- a/ios/Sources/PlugchoiceSDK/Network/HTTP1.swift +++ b/ios/Sources/PlugchoiceSDK/Network/HTTP1.swift @@ -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() + } } } diff --git a/ios/Sources/PlugchoiceSDK/Network/HTTPService.swift b/ios/Sources/PlugchoiceSDK/Network/HTTPService.swift index 8214627..e44644f 100644 --- a/ios/Sources/PlugchoiceSDK/Network/HTTPService.swift +++ b/ios/Sources/PlugchoiceSDK/Network/HTTPService.swift @@ -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 { @@ -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 } } @@ -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 } @@ -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()), ])) } diff --git a/ios/Sources/PlugchoiceSDK/Network/HTTPSessions.swift b/ios/Sources/PlugchoiceSDK/Network/HTTPSessions.swift index 4aaa074..1051d00 100644 --- a/ios/Sources/PlugchoiceSDK/Network/HTTPSessions.swift +++ b/ios/Sources/PlugchoiceSDK/Network/HTTPSessions.swift @@ -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) -> Void @@ -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() } diff --git a/ios/Tests/PlugchoiceSDKTests/HTTPSessionTests.swift b/ios/Tests/PlugchoiceSDKTests/HTTPSessionTests.swift index e194547..e7fffc2 100644 --- a/ios/Tests/PlugchoiceSDKTests/HTTPSessionTests.swift +++ b/ios/Tests/PlugchoiceSDKTests/HTTPSessionTests.swift @@ -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( diff --git a/ios/Tests/PlugchoiceSDKTests/RequestParamsTests.swift b/ios/Tests/PlugchoiceSDKTests/RequestParamsTests.swift index c2e25c7..a180c3f 100644 --- a/ios/Tests/PlugchoiceSDKTests/RequestParamsTests.swift +++ b/ios/Tests/PlugchoiceSDKTests/RequestParamsTests.swift @@ -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() { @@ -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": "{}"])))