diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java new file mode 100644 index 00000000000..e507cb64a06 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend; + +import com.codename1.backend.HttpProxy; +import com.codename1.backend.HttpServer; +import com.codename1.backend.Web; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestMapping; +import com.codename1.backend.annotations.RestController; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collections; +import java.util.Map; + +/** Streaming request and response bodies, and a reverse proxy. */ +@RestController +public class Streaming { + private final HttpProxy images; + + public Streaming() throws IOException { + // tag::backend-web-proxy-create[] + images = new HttpProxy("https://images.internal:8443") + .allowHost("cdn.internal"); + // end::backend-web-proxy-create[] + } + + // tag::backend-web-upload-stream[] + @PostMapping("/archive") + public Map archive(HttpServer.Request request) throws IOException { + long total = 0; + byte[] buffer = new byte[64 * 1024]; + InputStream in = request.getBodyStream(); + try { + for (int n = in.read(buffer); n > 0; n = in.read(buffer)) { + total += n; // store, hash or forward each block + } + } finally { + in.close(); + } + return Collections.singletonMap("bytes", total); + } + // end::backend-web-upload-stream[] + + // tag::backend-web-download-stream[] + @GetMapping("/report.csv") + public HttpServer.Response report() { + return HttpServer.Response.stream(200, "text/csv", new HttpServer.BodyWriter() { + public void writeTo(OutputStream out) throws IOException { + for (int row = 0; row < 1000000; row++) { + out.write((row + "," + row * row + "\n").getBytes("UTF-8")); + } + } + }); + } + // end::backend-web-download-stream[] + + // tag::backend-web-exchange[] + static long upload(InputStream file, long length) throws IOException { + Web.Exchange exchange = Web.open("PUT", "https://storage.internal/blobs/1", + Collections.singletonList("Content-Type: application/octet-stream"), length); + try { + OutputStream up = exchange.getRequestBody(); + byte[] buffer = new byte[64 * 1024]; + for (int n = file.read(buffer); n > 0; n = file.read(buffer)) { + up.write(buffer, 0, n); + } + up.close(); + if (exchange.getStatus() >= 300) { + throw new IOException("storage answered " + exchange.getStatus()); + } + InputStream down = exchange.getResponseBody(); + long answered = 0; + while (down.read() >= 0) { + answered++; + } + return answered; + } finally { + exchange.close(); // before the end, this cancels it + } + } + // end::backend-web-exchange[] + + // tag::backend-web-proxy-forward[] + @RequestMapping("/images/{name}") + public HttpServer.Response image(HttpServer.Request request) { + return images.forward(request); // the same path and query, upstream + } + // end::backend-web-proxy-forward[] +} diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 3d0c9ad49e7..545465636bc 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -254,6 +254,48 @@ for any other body. A body that isn't text -- an upload, an image, form, after checking that it's valid UTF-8, and refuses a binary body rather than hand the handler garbled characters. +==== Large uploads and downloads + +A binary body can be larger than 8 MB when `CN1_HTTP_MAX_SPOOLED_BODY_MB` raises +its limit. Such a body is never held in memory whole. Up to +`CN1_HTTP_SPOOL_THRESHOLD_KB` (1024 by default) it stays on the heap, and past +that the server writes it to a temporary file as it arrives. The file is deleted +from the directory as soon as it's created, so a crash leaves nothing behind. +`CN1_HTTP_SPOOL_DIR` chooses the directory, the system temporary directory +otherwise. A body over the limit is answered 413, over HTTP/1.1 and HTTP/2 +alike. JSON, forms, XML and compressed bodies keep the 8 MB limit, because a +handler reads those whole anyway. + +Read such a body with `getBodyStream()`, which reads the file or the memory +without the handler knowing which. `getBodyLength()` gives its size without +reading it. A multipart upload is spooled the same way, and each +`HttpServer.Part` has `getInputStream()` and `getLength()` for it: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-upload-stream,indent=0] +---- + +The body is valid until the request ends. A handler that needs it later copies +it somewhere of its own. + +A response is streamed with `HttpServer.Response.stream`. The writer runs after +the headers are sent and writes as much as it likes: chunked on HTTP/1.1, DATA +frames on HTTP/2. A write blocks while the client is slow, so a slow client +slows the writer rather than filling memory, and a client that goes away makes +the next write throw. + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-download-stream,indent=0] +---- + +The server doesn't run the writer for a `HEAD` request, a status without a +body, or a client that left before the headers went out. A writer that holds +something open until it runs, such as a connection or a file, should also +implement `java.io.Closeable`. In those cases the server calls `close()` +instead. + ==== Compression `cn1.server.compression.enabled=true` turns on gzip for responses, with the two @@ -765,6 +807,63 @@ HTTP/2 fails every request with an exception that says so. It reads PKCS#12 archives, and PEM keys in unencrypted PKCS#8 or RSA form. The packaged server also reads encrypted PEM keys. A PKCS#12 archive works the same in both. +==== Streaming a request + +`Web.request` holds the whole response in memory. `Web.open` (or +`WebClient.open`) returns a `Web.Exchange` instead, which sends the request body +as it's written and reads the response as it arrives. The length is the body's +size, `Web.UNKNOWN_LENGTH` for a body of unknown size, or `Web.NO_BODY`: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-exchange,indent=0] +---- + +Write and close the request body first, then read the status, the headers and +the response body, all from one thread. Each direction buffers a fixed window, +so a slow peer slows its producer. `close()` ends the exchange in any state. +Before the response has been read to its end it cancels the exchange, closing +the connection instead of draining it, so always close it in a `finally`. An +exchange never follows a redirect, because a streamed body can't be sent twice. + +==== Forwarding to another service + +`HttpProxy` forwards a request to another server and streams the answer back, +the way a gateway in front of an internal service would: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-proxy-create,indent=0] +---- + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-proxy-forward,indent=0] +---- + +The response body is streamed back as it arrives, and a binary upload is +streamed up from wherever it lives, a spooled one included. A text or compressed +request body is read whole on arrival, under the 8 MB limit, and forwarded +decompressed. Every end-to-end header goes +through, a repeated one such as `Set-Cookie` line by line. The hop-by-hop +headers (`Connection`, `Keep-Alive`, `Proxy-*`, `TE`, `Trailer`, +`Transfer-Encoding`, `Upgrade`) and any header a `Connection` field names are +removed in both directions. `X-Forwarded-For`, `X-Forwarded-Proto` and +`X-Forwarded-Host` are added unless `setForwardedHeaders(false)` turns them off. + +Only the target's host is reached, plus the hosts named with `allowHost`, over +`http` and `https`. Redirects are passed back to the client. +`setFollowRedirects(n)` follows up to `n` hops itself, for a request without a +body and only to allowed hosts. A hop to another host doesn't carry the +client's `Authorization` and `Cookie` headers. + +Failures go both ways. A client that disconnects cancels the upstream exchange. +An upstream that fails before answering is a `502`. Once the status has been +sent, a later upstream failure aborts the client's response, closing the +HTTP/1.1 connection or resetting the HTTP/2 stream, so the client sees a +truncated response and never one that looks complete. `setClient` gives the +proxy a `WebClient` for its timeouts and client certificate. + A static binary in a `scratch` image has no certificate store. Copy a `ca-certificates.crt` into the image and name it in `CURL_CA_BUNDLE` or `SSL_CERT_FILE`. diff --git a/maven/backend/src/test/java/com/codename1/backend/HttpStreamingTest.java b/maven/backend/src/test/java/com/codename1/backend/HttpStreamingTest.java new file mode 100644 index 00000000000..d127d3bb123 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/HttpStreamingTest.java @@ -0,0 +1,551 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.CRC32; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Streaming request bodies and multipart parts past the spool threshold, the +/// streamed outbound exchange, and the forwarding proxy -- real sockets, with +/// the default limits (1 MiB to disk, 8 MiB in all). +class HttpStreamingTest { + private final List backends = new ArrayList(); + + @AfterEach + void stop() { + for (Object each : backends) { + ((Backend) each).stop(); + } + } + + private static final int BIG = 3 * 1024 * 1024 + 17; + private static final AtomicBoolean WRITER_FAILED = new AtomicBoolean(); + private static final CountDownLatch WRITER_DONE = new CountDownLatch(1); + private static final AtomicReference SEEN_HEADERS = new AtomicReference(); + private static final java.util.concurrent.atomic.AtomicInteger UNRUN_RAN = + new java.util.concurrent.atomic.AtomicInteger(); + private static final java.util.concurrent.atomic.AtomicInteger UNRUN_CLOSED = + new java.util.concurrent.atomic.AtomicInteger(); + + /// A writer holding a resource, for the responses the server never writes. + private static final class Held implements HttpServer.BodyWriter, java.io.Closeable { + public void writeTo(OutputStream output) throws IOException { + UNRUN_RAN.incrementAndGet(); + output.write('x'); + } + + public void close() { + UNRUN_CLOSED.incrementAndGet(); + } + } + + /// The proxies forward to this same backend (one runs per process): a + /// request under /proxy goes through `proxy`, everything else is the + /// upstream's own route. + private volatile HttpProxy proxy; + private volatile HttpProxy following; + private volatile HttpProxy crossing; + private volatile int selfPort; + + private int upstream() throws Exception { + selfPort = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(selfPort)); + backends.add(Backend.builder(Config.of(settings, "test")).quiet().handler(handler()) + .start()); + proxy = new HttpProxy("http://127.0.0.1:" + selfPort); + following = new HttpProxy("http://127.0.0.1:" + selfPort).setFollowRedirects(3); + crossing = new HttpProxy("http://127.0.0.1:" + selfPort).allowHost("localhost") + .setFollowRedirects(3); + return selfPort; + } + + private HttpServer.Handler handler() { + return new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + String path = request.getTarget(); + if (path.startsWith("/proxy/elsewhere")) { + return proxy.forward(request, "http://example.com/"); + } + if (path.startsWith("/proxy/cross/")) { + return crossing.forward(request, path.substring("/proxy/cross".length())); + } + if (path.startsWith("/toself")) { + return HttpServer.Response.empty(302, null, null).header("Location", "/echo"); + } + if (path.startsWith("/tolocal")) { + return HttpServer.Response.empty(302, null, null) + .header("Location", "http://localhost:" + selfPort + "/echo"); + } + if (path.startsWith("/proxy/follow")) { + return following.forward(request, "/redirect"); + } + if (path.startsWith("/proxy/raw/")) { + return new HttpProxy("http://127.0.0.1:" + + path.substring("/proxy/raw/".length())).forward(request, "/x"); + } + if (path.startsWith("/proxy/")) { + return proxy.forward(request, path.substring("/proxy".length())); + } + if (path.startsWith("/unrun")) { + int status = path.equals("/unrun/204") ? 204 : 200; + return HttpServer.Response.stream(status, "text/plain", new Held()); + } + if (path.startsWith("/endless")) { + return HttpServer.Response.stream(200, "application/octet-stream", + new HttpServer.BodyWriter() { + public void writeTo(OutputStream output) throws IOException { + byte[] block = pattern(64 * 1024); + try { + for (int iter = 0 ; iter < 16384 ; iter++) { + output.write(block); + } + } catch (IOException err) { + WRITER_FAILED.set(true); + throw err; + } finally { + WRITER_DONE.countDown(); + } + } + }); + } + if (path.startsWith("/sum")) { + // Read as a stream, never as an array: the spooled path. + CRC32 crc = new CRC32(); + long n = 0; + InputStream in = request.getBodyStream(); + byte[] buffer = new byte[8192]; + int r; + while ((r = in.read(buffer)) > 0) { + crc.update(buffer, 0, r); + n += r; + } + return HttpServer.Response.text(200, n + ":" + crc.getValue() + ":" + + request.getBodyLength()); + } + if (path.startsWith("/parts")) { + StringBuilder sb = new StringBuilder(); + for (Object each : request.getParts()) { + HttpServer.Part p = (HttpServer.Part) each; + CRC32 crc = new CRC32(); + InputStream in = p.getInputStream(); + byte[] buffer = new byte[8192]; + int r; + while ((r = in.read(buffer)) > 0) { + crc.update(buffer, 0, r); + } + sb.append(p.getName()).append('=').append(p.getLength()).append('/') + .append(crc.getValue()); + if (p.getFilename() == null) { + sb.append('/').append(p.getText()); + } + sb.append(';'); + } + return HttpServer.Response.text(200, sb.toString()); + } + if (path.startsWith("/download")) { + return HttpServer.Response.stream(200, "application/octet-stream", + new HttpServer.BodyWriter() { + public void writeTo(OutputStream output) throws IOException { + output.write(pattern(BIG)); + } + }); + } + if (path.startsWith("/echo")) { + final InputStream in = request.getBodyStream(); + SEEN_HEADERS.set(request.getHeaders()); + return HttpServer.Response.stream(200, request.getHeader("Content-Type"), + new HttpServer.BodyWriter() { + public void writeTo(OutputStream output) throws IOException { + byte[] buffer = new byte[8192]; + int r; + while ((r = in.read(buffer)) > 0) { + output.write(buffer, 0, r); + } + } + }).header("Set-Cookie", "a=1"); + } + if (path.startsWith("/redirect")) { + return HttpServer.Response.empty(302, null, null) + .header("Location", "/sum"); + } + return HttpServer.Response.text(404, "no"); + } + }; + } + + static byte[] pattern(int size) { + byte[] out = new byte[size]; + for (int iter = 0 ; iter < size ; iter++) { + out[iter] = (byte) (iter * 31 + (iter >> 9)); + } + return out; + } + + static long crc(byte[] data) { + CRC32 crc = new CRC32(); + crc.update(data, 0, data.length); + return crc.getValue(); + } + + private static String post(int port, String path, String type, byte[] body, boolean chunked) + throws IOException { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) + .openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", type); + if (chunked) { + c.setChunkedStreamingMode(64 * 1024); + } else { + c.setFixedLengthStreamingMode(body.length); + } + OutputStream out = c.getOutputStream(); + out.write(body); + out.close(); + int status = c.getResponseCode(); + InputStream in = status >= 400 ? c.getErrorStream() : c.getInputStream(); + String text = in == null ? "" : new String(readAll(in), StandardCharsets.UTF_8); + return status + " " + text; + } + + static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } + + @Test + void aLargeUploadIsSpooledAndStreamedToTheHandler() throws Exception { + int port = upstream(); + byte[] body = pattern(BIG); + String expected = "200 " + BIG + ":" + crc(body) + ":" + BIG; + assertEquals(expected, post(port, "/sum", "application/octet-stream", body, false)); + assertEquals(expected, post(port, "/sum", "application/octet-stream", body, true)); + } + + @Test + void aBinaryBodyPastTheLimitIs413OnBothFramings() throws Exception { + int port = upstream(); + byte[] body = pattern(9 * 1024 * 1024); + assertTrue(post(port, "/sum", "application/octet-stream", body, false).startsWith("413")); + String chunked; + try { + chunked = post(port, "/sum", "application/octet-stream", body, true); + } catch (IOException resetWhileSending) { + // The server answers 413 and closes as soon as the limit is passed, + // which a client still writing may see as a reset instead. + chunked = "413"; + } + assertTrue(chunked.startsWith("413"), chunked); + } + + @Test + void aLargeMultipartFileIsParsedInPlace() throws Exception { + int port = upstream(); + byte[] file = pattern(BIG); + ByteArrayOutputStream form = new ByteArrayOutputStream(); + String boundary = "XyZ123"; + form.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n" + + "hello\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; " + + "filename=\"a.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + form.write(file); + form.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.US_ASCII)); + String answer = post(port, "/parts", "multipart/form-data; boundary=" + boundary, + form.toByteArray(), false); + assertEquals("200 title=5/" + crc("hello".getBytes(StandardCharsets.US_ASCII)) + + "/hello;file=" + BIG + "/" + crc(file) + ";", answer); + } + + @Test + void anExchangeStreamsBothWays() throws Exception { + int port = upstream(); + Web.Exchange download = Web.open("GET", "http://127.0.0.1:" + port + "/download", null, + Web.NO_BODY); + try { + assertEquals(200, download.getStatus()); + assertEquals(crc(pattern(BIG)), crc(readAll(download.getResponseBody()))); + } finally { + download.close(); + } + byte[] body = pattern(BIG); + Web.Exchange upload = Web.open("POST", "http://127.0.0.1:" + port + "/sum", + listOf("Content-Type: application/octet-stream"), Web.UNKNOWN_LENGTH); + try { + OutputStream out = upload.getRequestBody(); + for (int at = 0 ; at < body.length ; at += 100000) { + out.write(body, at, Math.min(100000, body.length - at)); + } + out.close(); + assertEquals(200, upload.getStatus()); + assertEquals(BIG + ":" + crc(body) + ":" + BIG, + new String(readAll(upload.getResponseBody()), StandardCharsets.UTF_8)); + } finally { + upload.close(); + } + } + + @Test + void aRedirectToAnotherHostDropsTheClientsCredentials() throws Exception { + int port = upstream(); + String[] hops = {"/toself", "/tolocal"}; + for (int iter = 0; iter < hops.length; iter++) { + SEEN_HEADERS.set(null); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/proxy/cross" + hops[iter]).openConnection(); + c.setRequestProperty("Authorization", "Bearer secret"); + c.setRequestProperty("Cookie", "session=1"); + assertEquals(200, c.getResponseCode()); + readAll(c.getInputStream()); + Map seen = (Map) SEEN_HEADERS.get(); + boolean sameHost = iter == 0; + assertEquals(sameHost, seen.containsKey("authorization"), hops[iter]); + assertEquals(sameHost, seen.containsKey("cookie"), hops[iter]); + } + } + + @Test + void aGzipUploadIsForwardedDecoded() throws Exception { + int port = upstream(); + java.io.ByteArrayOutputStream packed = new java.io.ByteArrayOutputStream(); + java.util.zip.GZIPOutputStream gz = new java.util.zip.GZIPOutputStream(packed); + gz.write("{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + gz.close(); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/proxy/echo").openConnection(); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + c.setRequestProperty("Content-Encoding", "gzip"); + c.getOutputStream().write(packed.toByteArray()); + assertEquals(200, c.getResponseCode()); + assertEquals("{\"a\":1}", new String(readAll(c.getInputStream()), StandardCharsets.UTF_8)); + assertFalse(((Map) SEEN_HEADERS.get()).containsKey("content-encoding")); + } + + @Test + void aProxiedHeadKeepsTheUpstreamLength() throws Exception { + int port = upstream(); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/proxy/sum").openConnection(); + c.setRequestMethod("HEAD"); + assertEquals(200, c.getResponseCode()); + assertEquals("5", c.getHeaderField("Content-Length")); + c.disconnect(); + c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/proxy/download") + .openConnection(); + c.setRequestMethod("HEAD"); + assertEquals(200, c.getResponseCode()); + assertNull(c.getHeaderField("Content-Length"), "a length nobody gave was invented"); + } + + @Test + void aWriterTheServerNeverRunsIsClosed() throws Exception { + int port = upstream(); + UNRUN_RAN.set(0); + UNRUN_CLOSED.set(0); + java.net.HttpURLConnection c = (java.net.HttpURLConnection) + new java.net.URL("http://127.0.0.1:" + port + "/unrun/204").openConnection(); + assertEquals(204, c.getResponseCode()); + c.disconnect(); + c = (java.net.HttpURLConnection) + new java.net.URL("http://127.0.0.1:" + port + "/unrun/head").openConnection(); + c.setRequestMethod("HEAD"); + assertEquals(200, c.getResponseCode()); + c.disconnect(); + assertEquals(0, UNRUN_RAN.get(), "a bodiless response ran its writer"); + assertEquals(2, UNRUN_CLOSED.get(), "a writer that never ran was not closed"); + } + + @Test + void closingAnExchangeEarlyCancelsTheProducer() throws Exception { + int port = upstream(); + Web.Exchange download = Web.open("GET", "http://127.0.0.1:" + port + "/endless", null, + Web.NO_BODY); + assertEquals(200, download.getStatus()); + byte[] some = new byte[1024]; + assertTrue(download.getResponseBody().read(some) > 0); + download.close(); + assertTrue(WRITER_DONE.await(20, TimeUnit.SECONDS)); + assertTrue(WRITER_FAILED.get(), "the upstream writer never saw the cancellation"); + } + + @Test + void theProxyForwardsBodiesAndEndToEndHeaders() throws Exception { + int port = upstream(); + byte[] body = pattern(BIG); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/proxy/echo?x=1") + .openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/octet-stream"); + c.setRequestProperty("X-Keep", "kept"); + c.setFixedLengthStreamingMode(body.length); + OutputStream out = c.getOutputStream(); + out.write(body); + out.close(); + assertEquals(200, c.getResponseCode()); + assertEquals(crc(body), crc(readAll(c.getInputStream()))); + assertEquals("a=1", c.getHeaderField("Set-Cookie")); + Map seen = (Map) SEEN_HEADERS.get(); + assertEquals("kept", seen.get("x-keep")); + // HttpURLConnection replaces a Connection header with its own, so the + // hop-by-hop half goes over a raw socket. + Socket s = new Socket("127.0.0.1", port); + try { + s.setSoTimeout(20000); + s.getOutputStream().write(("GET /proxy/echo HTTP/1.1\r\nHost: x\r\n" + + "Connection: close, X-Drop\r\nX-Drop: secret\r\nTE: trailers\r\n" + + "X-Keep: again\r\n\r\n").getBytes("US-ASCII")); + String reply = new String(readAll(s.getInputStream()), "ISO-8859-1"); + assertTrue(reply.startsWith("HTTP/1.1 200"), reply); + } finally { + s.close(); + } + seen = (Map) SEEN_HEADERS.get(); + assertEquals("again", seen.get("x-keep")); + assertNull(seen.get("x-drop"), "a header Connection named was forwarded"); + assertNull(seen.get("te"), "a hop-by-hop header was forwarded"); + assertTrue(String.valueOf(seen.get("x-forwarded-for")).contains("127.0.0.1")); + assertEquals("http", seen.get("x-forwarded-proto")); + } + + @Test + void theProxyFiltersUpstreamHopByHopAndKeepsRepeatedHeaders() throws Exception { + final ServerSocket raw = new ServerSocket(0); + Thread server = new Thread(new Runnable() { + public void run() { + try { + Socket s = raw.accept(); + InputStream in = s.getInputStream(); + int state = 0; + while (state < 4) { + int b = in.read(); + if (b < 0) { + break; + } + state = (b == '\r' || b == '\n') ? state + 1 : 0; + } + byte[] payload = {0, 1, 2, (byte) 0xff, 13, 10}; + OutputStream o = s.getOutputStream(); + o.write(("HTTP/1.1 200 OK\r\nContent-Type: application/x-thing\r\n" + + "Set-Cookie: a=1\r\nSet-Cookie: b=2\r\nConnection: close, X-Hop\r\n" + + "X-Hop: gone\r\nKeep-Alive: timeout=5\r\nX-End: here\r\n" + + "Content-Length: " + payload.length + "\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + o.write(payload); + o.flush(); + s.close(); + } catch (IOException ignored) { + // the test fails on the client side + } + } + }); + server.setDaemon(true); + server.start(); + try { + int port = upstream(); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/proxy/raw/" + raw.getLocalPort()).openConnection(); + assertEquals(200, c.getResponseCode()); + byte[] got = readAll(c.getInputStream()); + assertEquals(6, got.length); + assertEquals((byte) 0xff, got[3]); + assertEquals("application/x-thing", c.getContentType()); + List cookies = null; + for (Map.Entry> e : c.getHeaderFields().entrySet()) { + if ("set-cookie".equalsIgnoreCase(e.getKey())) { + cookies = e.getValue(); + } + } + assertEquals(2, cookies.size(), String.valueOf(cookies)); + assertEquals("here", c.getHeaderField("X-End")); + assertNull(c.getHeaderField("X-Hop")); + assertNull(c.getHeaderField("Keep-Alive")); + } finally { + raw.close(); + } + } + + @Test + void theProxyRefusesOtherDestinationsAndPassesRedirectsThrough() throws Exception { + int port = upstream(); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/proxy/elsewhere").openConnection(); + assertEquals(502, c.getResponseCode()); + c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/proxy/redirect") + .openConnection(); + c.setInstanceFollowRedirects(false); + assertEquals(302, c.getResponseCode()); + assertEquals("/sum", c.getHeaderField("Location")); + c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/proxy/follow") + .openConnection(); + assertEquals(200, c.getResponseCode()); + assertEquals("0:0:0", new String(readAll(c.getInputStream()), StandardCharsets.UTF_8)); + assertFalse(HttpProxy.isHopByHop("x-end")); + assertNull(HttpProxy.hostOf("http://user@evil/")); + } + + private static List listOf(String line) { + List out = new ArrayList(); + out.add(line); + return out; + } + + private static int freePort() throws IOException { + ServerSocket s = new ServerSocket(0); + try { + return s.getLocalPort(); + } finally { + s.close(); + } + } +} diff --git a/vm/backend/demo/streamcheck/com/demo/StreamCheck.java b/vm/backend/demo/streamcheck/com/demo/StreamCheck.java new file mode 100644 index 00000000000..a88e262eb5c --- /dev/null +++ b/vm/backend/demo/streamcheck/com/demo/StreamCheck.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.demo; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import com.codename1.backend.HttpProxy; +import com.codename1.backend.HttpServer; +import com.codename1.backend.Web; + +/// Streaming bodies on whichever runtime this is built for: a spooled upload, +/// a multipart file parsed in place, an outbound exchange in both directions, +/// cancellation and the proxy. +/// +/// With no argument it serves itself on an ephemeral port and runs every check +/// as its own client, then prints STREAMCHECK OK. With `serve ` it only +/// serves, so tests/stream-check.sh can send the same uploads over HTTP/1.1 and +/// cleartext HTTP/2 and compare the answers. +public class StreamCheck { + private static final int BIG = 3 * 1024 * 1024; + private static int passed; + private static final List failures = new ArrayList(); + private static volatile boolean writerFailed; + private static volatile boolean writerDone; + private static volatile String seenDrop = ""; + private static volatile String seenKeep = ""; + private static HttpProxy proxy; + private static HttpProxy elsewhere; + + public static void main(String[] args) throws Exception { + if (args != null && args.length == 2 && "serve".equals(args[0])) { + HttpServer.start("127.0.0.1", Integer.parseInt(args[1]), 64, 4, handler()); + System.out.println("STREAMCHECK SERVING"); + while (true) { + Thread.sleep(60000); + } + } + HttpServer server = HttpServer.start("127.0.0.1", 0, 64, 4, handler()); + String base = "http://127.0.0.1:" + server.getPort(); + proxy = new HttpProxy(base); + elsewhere = new HttpProxy(base); + try { + upload(base); + download(base); + cancellation(base); + proxied(base); + } finally { + server.stop(2000); + } + System.out.println("passed=" + passed + " failed=" + failures.size()); + for (int iter = 0 ; iter < failures.size() ; iter++) { + System.out.println("FAIL " + failures.get(iter)); + } + System.out.println(failures.isEmpty() ? "STREAMCHECK OK" : "STREAMCHECK FAILED"); + if (!failures.isEmpty()) { + System.exit(1); + } + } + + private static void upload(String base) throws IOException { + Web.Exchange up = Web.open("POST", base + "/sum", header( + "Content-Type: application/octet-stream"), BIG); + try { + OutputStream out = up.getRequestBody(); + byte[] block = pattern(64 * 1024); + for (int iter = 0 ; iter < BIG / block.length ; iter++) { + out.write(block); + } + out.close(); + check("a large upload reaches the handler as a stream", "200 " + BIG + " " + + sum(pattern(64 * 1024)) * (BIG / (64 * 1024)), + up.getStatus() + " " + text(up.getResponseBody())); + } finally { + up.close(); + } + } + + private static void download(String base) throws IOException { + Web.Exchange down = Web.open("GET", base + "/big", null, Web.NO_BODY); + try { + InputStream in = down.getResponseBody(); + byte[] buffer = new byte[16384]; + long total = 0; + long s = 0; + int n; + while ((n = in.read(buffer)) > 0) { + total += n; + s += sum(buffer, n); + } + check("a streamed response arrives whole", "200 " + BIG + " " + + sum(pattern(64 * 1024)) * (BIG / (64 * 1024)), + down.getStatus() + " " + total + " " + s); + } finally { + down.close(); + } + } + + private static void cancellation(String base) throws Exception { + Web.Exchange down = Web.open("GET", base + "/endless", null, Web.NO_BODY); + byte[] some = new byte[1024]; + int got = down.getResponseBody().read(some); + down.close(); + long deadline = System.currentTimeMillis() + 20000; + while (!writerDone && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + check("closing an exchange early stops the producer", "true true true", + (got > 0) + " " + writerDone + " " + writerFailed); + } + + private static void proxied(String base) throws IOException { + List headers = header("Content-Type: application/octet-stream"); + headers.add("X-Keep: kept"); + Web.Exchange up = Web.open("POST", base + "/proxy/sum", headers, BIG); + try { + OutputStream out = up.getRequestBody(); + byte[] block = pattern(64 * 1024); + for (int iter = 0 ; iter < BIG / block.length ; iter++) { + out.write(block); + } + out.close(); + check("the proxy streams a body through", "200 " + BIG + " " + + sum(pattern(64 * 1024)) * (BIG / (64 * 1024)), + up.getStatus() + " " + text(up.getResponseBody())); + check("and forwards end-to-end headers", "kept", seenKeep); + } finally { + up.close(); + } + Web.Result refused = Web.request("GET", base + "/proxy-elsewhere", null, null); + check("the proxy refuses a destination it was not given", "502", + String.valueOf(refused.getStatus())); + // An exchange never follows a redirect itself, so this sees what the + // proxy answered. + Web.Exchange redirect = Web.open("GET", base + "/proxy/redirect", null, Web.NO_BODY); + try { + check("and passes a redirect through rather than following it", "302 /sum", + redirect.getStatus() + " " + redirect.getHeader("Location")); + } finally { + redirect.close(); + } + } + + private static HttpServer.Handler handler() { + return new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + String path = request.getTarget(); + if (path.startsWith("/proxy-elsewhere")) { + return elsewhere.forward(request, "http://example.com/"); + } + if (path.startsWith("/proxy/")) { + return proxy.forward(request, path.substring("/proxy".length())); + } + if (path.startsWith("/redirect")) { + HttpServer.Response r = HttpServer.Response.text(302, "moved"); + r.header("Location", "/sum"); + return r; + } + if (path.startsWith("/sum")) { + seenKeep = String.valueOf(request.getHeader("X-Keep")); + seenDrop = String.valueOf(request.getHeader("X-Drop")); + InputStream in = request.getBodyStream(); + byte[] buffer = new byte[16384]; + long total = 0; + long s = 0; + int n; + while ((n = in.read(buffer)) > 0) { + total += n; + s += sum(buffer, n); + } + return HttpServer.Response.text(200, total + " " + s); + } + if (path.startsWith("/parts")) { + List parts = request.getParts(); + StringBuilder out = new StringBuilder(); + for (int iter = 0 ; iter < parts.size() ; iter++) { + HttpServer.Part part = (HttpServer.Part) parts.get(iter); + InputStream in = part.getInputStream(); + byte[] buffer = new byte[16384]; + long s = 0; + int n; + while ((n = in.read(buffer)) > 0) { + s += sum(buffer, n); + } + out.append(part.getName()).append('=').append(part.getLength()) + .append('/').append(s).append(' '); + } + return HttpServer.Response.text(200, out.toString().trim()); + } + if (path.startsWith("/headers")) { + return HttpServer.Response.text(200, "drop=" + seenDrop + " keep=" + seenKeep); + } + if (path.startsWith("/big")) { + return HttpServer.Response.stream(200, "application/octet-stream", + new HttpServer.BodyWriter() { + public void writeTo(OutputStream output) throws IOException { + byte[] block = pattern(64 * 1024); + for (int iter = 0 ; iter < BIG / block.length ; iter++) { + output.write(block); + } + } + }); + } + if (path.startsWith("/endless")) { + writerDone = false; + writerFailed = false; + return HttpServer.Response.stream(200, "application/octet-stream", + new HttpServer.BodyWriter() { + public void writeTo(OutputStream output) throws IOException { + byte[] block = pattern(64 * 1024); + try { + for (int iter = 0 ; iter < 16384 ; iter++) { + output.write(block); + } + } catch (IOException err) { + writerFailed = true; + throw err; + } finally { + writerDone = true; + } + } + }); + } + return HttpServer.Response.text(404, "no"); + } + }; + } + + private static byte[] pattern(int length) { + byte[] out = new byte[length]; + for (int iter = 0 ; iter < length ; iter++) { + out[iter] = (byte) (iter * 31 + 7); + } + return out; + } + + private static long sum(byte[] data) { + return sum(data, data.length); + } + + private static long sum(byte[] data, int length) { + long s = 0; + for (int iter = 0 ; iter < length ; iter++) { + s += data[iter] & 0xff; + } + return s; + } + + private static String text(InputStream in) throws IOException { + StringBuilder out = new StringBuilder(); + int c; + while ((c = in.read()) >= 0) { + out.append((char) c); + } + return out.toString(); + } + + private static List header(String line) { + List out = new ArrayList(); + out.add(line); + return out; + } + + private static void check(String name, String expected, String actual) { + if (expected.equals(actual)) { + passed++; + System.out.println("ok " + name); + } else { + failures.add(name + ": expected <" + expected + "> but was <" + actual + ">"); + System.out.println("FAIL " + name + ": expected <" + expected + "> but was <" + + actual + ">"); + } + } +} diff --git a/vm/backend/impl/javase/com/codename1/backend/FileIo.java b/vm/backend/impl/javase/com/codename1/backend/FileIo.java index 1ee6e57e28d..dcbb4b25fb4 100644 --- a/vm/backend/impl/javase/com/codename1/backend/FileIo.java +++ b/vm/backend/impl/javase/com/codename1/backend/FileIo.java @@ -465,8 +465,85 @@ public static String realPath(String path) { } } + /// A spool file's channel. Deleted as soon as it is open where the platform + /// allows that, and on close where it does not, so the packaged arm's + /// "closing is the cleanup" holds here too. + private static final class SpoolFile { + final FileChannel channel; + + SpoolFile(FileChannel channel) { + this.channel = channel; + } + } + + public static int createTemp(String dir) { + try { + Path parent = dir == null || dir.length() == 0 + ? Paths.get(System.getProperty("java.io.tmpdir")) : Paths.get(dir); + Path file = Files.createTempFile(parent, "cn1-spool-", ""); + FileChannel channel = FileChannel.open(file, StandardOpenOption.READ, //NOPMD CloseResource - owned by the descriptor table once added, closed here otherwise + StandardOpenOption.WRITE, StandardOpenOption.DELETE_ON_CLOSE); + boolean added = false; + try { + try { + Files.deleteIfExists(file); + } catch (IOException stillOpenOnWindows) { + // DELETE_ON_CLOSE removes it when the channel closes instead. + } + int fd = Descriptors.add(new SpoolFile(channel)); + added = true; + return fd; + } finally { + if (!added) { + channel.close(); + } + } + } catch (IOException | RuntimeException err) { + return -1; + } + } + + public static boolean write(int fd, byte[] buffer, int offset, int length) { + Object entry = Descriptors.get(fd); + if (!(entry instanceof SpoolFile)) { + return false; + } + try { + ByteBuffer source = ByteBuffer.wrap(buffer, offset, length); + FileChannel channel = ((SpoolFile) entry).channel; //NOPMD CloseResource - borrowed from the descriptor table, which closes it + while (source.hasRemaining()) { + channel.write(source, channel.size()); + } + return true; + } catch (IOException | RuntimeException err) { + return false; + } + } + + public static int pread(int fd, byte[] buffer, int offset, int length, long position) { + Object entry = Descriptors.get(fd); + if (!(entry instanceof SpoolFile)) { + return -1; + } + try { + int n = ((SpoolFile) entry).channel.read(ByteBuffer.wrap(buffer, offset, length), + position); + return n < 0 ? 0 : n; + } catch (IOException | RuntimeException err) { + return -1; + } + } + public static void close(int fd) { Object entry = Descriptors.remove(fd); + if (entry instanceof SpoolFile) { + try { + ((SpoolFile) entry).channel.close(); + } catch (IOException ignored) { + // already gone + } + return; + } if (entry instanceof OpenFile) { FileChannel channel = ((OpenFile) entry).channel; //NOPMD CloseResource - closed just below if (channel != null) { diff --git a/vm/backend/impl/javase/com/codename1/backend/Http2.java b/vm/backend/impl/javase/com/codename1/backend/Http2.java index 062df877809..635910175e0 100644 --- a/vm/backend/impl/javase/com/codename1/backend/Http2.java +++ b/vm/backend/impl/javase/com/codename1/backend/Http2.java @@ -56,6 +56,7 @@ public static final class Stream { final String authority; final Map headers; final byte[] body; + private long bodyLength; Stream(int id, String method, String path, String authority, Map headers, byte[] body) { this.id = id; @@ -64,6 +65,7 @@ public static final class Stream { this.authority = authority; this.headers = headers; this.body = body; + this.bodyLength = body == null ? 0 : body.length; } public int getId() { @@ -87,10 +89,23 @@ public Map getHeaders() { } /// The body as it ARRIVED, so the caller can check it before decoding. + /// Empty when the body was spooled; see [#takeBodyFd]. public byte[] getBody() { return body; } + /// The descriptor of a body that went to disk on arrival (an unlinked + /// temporary file), or -1 when it is in [#getBody]. Handed over once: + /// the caller owns closing it, and a second call answers -1. + public int takeBodyFd() { + return -1; + } + + /// The body's whole length, wherever it is. + public long getBodyLength() { + return bodyLength; + } + public String getBodyAsString() { if (body == null || body.length == 0) { return null; @@ -127,6 +142,10 @@ public boolean respond(int streamId, int status, String contentType, List extraH } /// Nothing is ever submitted here, so there is no ceiling to enforce. + /// Nothing to set: this arm does not serve HTTP/2. + public static void setBodyLimits(long limit, int spoolThreshold, String dir) { + } + public static void setMaxBodyBytes(long limit) { } diff --git a/vm/backend/impl/javase/com/codename1/backend/Web.java b/vm/backend/impl/javase/com/codename1/backend/Web.java index 06428299d88..7c012a725f9 100644 --- a/vm/backend/impl/javase/com/codename1/backend/Web.java +++ b/vm/backend/impl/javase/com/codename1/backend/Web.java @@ -703,4 +703,326 @@ public void run() { } } } + + /// The request has no body. + public static final long NO_BODY = -2; + /// The request has a body whose length is not known in advance: chunked on + /// HTTP/1.1, DATA frames until the end on HTTP/2. + public static final long UNKNOWN_LENGTH = -1; + + /// Opens a streamed exchange; see [Exchange]. Every default, no pool. + public static Exchange open(String method, String url, List headers, long requestLength) + throws IOException { + return open(null, method, url, headers, requestLength); + } + + static Exchange open(WebClient client, String method, String url, List headers, + long requestLength) throws IOException { + if (client != null && client.isRequireHttp2()) { + throw new IOException("The local Java SE runtime cannot make an HTTP/2 " + + "request; see Web.request"); + } + Urls.requireHttp(url); + HeaderLines.requireMethod(method); + HeaderLines.validate(headers); + if (requestLength < NO_BODY) { + throw new IllegalArgumentException("requestLength " + requestLength); + } + int connectTimeout = 30000; + int readTimeout = 30000; + SSLSocketFactory sockets = null; + if (client != null) { + connectTimeout = client.getConnectTimeoutMillis(); + readTimeout = client.getReadTimeoutMillis(); + sockets = ((Transport) client.transport(TRANSPORTS)).sockets; + } + HttpURLConnection connection; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + } catch (IOException err) { + throw new IOException("Request to " + Urls.forMessage(url) + " failed: " + + err.getMessage(), err); + } + if (sockets != null && connection instanceof HttpsURLConnection) { + ((HttpsURLConnection) connection).setSSLSocketFactory(sockets); + } + String verb = method == null ? "GET" : method; + try { + connection.setRequestMethod(verb); + } catch (java.net.ProtocolException unsupported) { + connection.disconnect(); + throw new IOException("The local Java SE runtime cannot send " + verb + + "; see Web.request", unsupported); + } + if (requestLength != NO_BODY && "GET".equals(verb)) { + connection.disconnect(); + throw new IOException("The local Java SE runtime cannot send a GET with a " + + "body; see Web.request"); + } + connection.setConnectTimeout(connectTimeout); + connection.setReadTimeout(readTimeout); + // Never followed by the transport: a streamed body cannot be sent twice, + // and a caller that follows redirects (HttpProxy) has to see each hop to + // apply its own destination rules to it. + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "codenameone-backend"); + if (headers != null) { + for (Object line : headers) { + String header = String.valueOf(line); + int colon = header.indexOf(':'); + if (colon > 0) { + connection.addRequestProperty(header.substring(0, colon).trim(), + header.substring(colon + 1).trim()); + } + } + } + if (requestLength != NO_BODY) { + connection.setDoOutput(true); + if (requestLength >= 0) { + connection.setFixedLengthStreamingMode(requestLength); + } else { + connection.setChunkedStreamingMode(16 * 1024); + } + } + return new Exchange(client, connection, url, requestLength != NO_BODY); + } + + /// One streamed HTTP exchange: the request body written as it is produced, + /// the response read as it arrives, neither held in memory whole. The way to + /// move a large upload or download, or to forward one (see HttpProxy). + /// + /// Use it from one thread, in order: write and close [#getRequestBody] when + /// the request has one, then [#getStatus] and the headers, then read + /// [#getResponseBody]. Writes block until the transport has taken the bytes + /// and reads until some have arrived, so a slow peer at either end slows the + /// producer rather than filling memory -- each direction buffers a fixed + /// window. [#close] ends the exchange whatever state it is in; before the + /// response has been read to its end that CANCELS it, closing the upstream + /// connection rather than draining it. Always close it, in a finally. + /// + /// Redirects are never followed here: a streamed body cannot be replayed. + /// The request timeout of a WebClient does not apply either -- a stream may + /// legitimately run for as long as it moves -- while its connect and read + /// timeouts do, the read timeout bounding each wait for the peer. + public static final class Exchange { + private final WebClient client; + private final HttpURLConnection connection; + private final String url; + private final boolean hasBody; + private OutputStream requestBody; + private boolean requestDone; + private int status = -1; + private Map values; + private Map firstValues; + private InputStream responseBody; + private boolean closed; + private boolean finished; + + Exchange(WebClient client, HttpURLConnection connection, String url, boolean hasBody) { + this.client = client; + this.connection = connection; + this.url = url; + this.hasBody = hasBody; + this.requestDone = !hasBody; + } + + /// Where the request body goes. Closing it ends the request body; the + /// stream is the exchange's and closing it does not end the exchange. + public OutputStream getRequestBody() throws IOException { + if (!hasBody) { + throw new IllegalStateException("this exchange was opened with NO_BODY"); + } + requireOpen(); + if (requestBody == null) { + final OutputStream raw; //NOPMD CloseResource - the wrapper below closes it, and close() of the exchange disconnects + try { + raw = connection.getOutputStream(); + } catch (IOException err) { + throw failed(err); + } + requestBody = new OutputStream() { + @Override + public void write(int b) throws IOException { + write(new byte[] {(byte) b}, 0, 1); + } + + @Override + public void write(byte[] data, int offset, int length) throws IOException { + if (requestDone) { + throw new IOException("the request body has been closed"); + } + requireOpen(); + try { + raw.write(data, offset, length); + } catch (IOException err) { + throw failed(err); + } + } + + @Override + public void flush() throws IOException { + if (!requestDone) { + raw.flush(); + } + } + + @Override + public void close() throws IOException { + if (!requestDone) { + requestDone = true; + try { + raw.close(); + } catch (IOException err) { + throw failed(err); + } + } + } + }; + } + return requestBody; + } + + /// The response status, waiting for the response headers. Ends a request + /// body that is still open first. + public int getStatus() throws IOException { + awaitResponse(); + return status; + } + + /// "HTTP/1.1" here; the packaged runtime also answers "HTTP/2". + public String getProtocol() throws IOException { + awaitResponse(); + return protocolOf(connection.getHeaderField(0)); + } + + /// The first value of every response header, by lower-case name. + public Map getHeaders() throws IOException { + awaitResponse(); + return firstValues; + } + + /// Every response header, by lower-case name, each a List of values in + /// arrival order -- Set-Cookie and the like keep every line. + public Map getHeaderValueMap() throws IOException { + awaitResponse(); + return values; + } + + public String getHeader(String name) throws IOException { + List all = getHeaderValues(name); + return all.isEmpty() ? null : (String) all.get(0); + } + + public List getHeaderValues(String name) throws IOException { + awaitResponse(); + List all = name == null ? null : (List) values.get(asciiLower(name)); + return all == null ? new ArrayList() : all; + } + + /// The response body as it arrives. Reading it to the end completes the + /// exchange; [#close] before that cancels it. + public InputStream getResponseBody() throws IOException { + awaitResponse(); + if (responseBody == null) { + InputStream in; //NOPMD CloseResource - the wrapper below owns it; close() of the exchange disconnects + try { + in = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + } catch (IOException err) { + in = connection.getErrorStream(); + } + final InputStream raw = in; //NOPMD CloseResource - closed by the wrapper it is handed to + responseBody = new InputStream() { + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n <= 0 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] target, int offset, int length) throws IOException { + requireOpen(); + if (raw == null || finished) { + return -1; + } + int n; + try { + n = raw.read(target, offset, length); + } catch (IOException err) { + throw failed(err); + } + if (n < 0) { + finished = true; + } + return n; + } + + @Override + public void close() throws IOException { + Exchange.this.close(); + } + }; + } + return responseBody; + } + + /// Ends the exchange. Before the response body has been read to its end + /// this cancels it: the connection is closed, never drained or reused. + public void close() { + if (closed) { + return; + } + closed = true; + connection.disconnect(); + if (client != null) { + client.exchangeEnded(); + } + } + + private void awaitResponse() throws IOException { + requireOpen(); + if (status >= 0) { + return; + } + if (hasBody && !requestDone) { + getRequestBody().close(); + } + try { + status = connection.getResponseCode(); + } catch (IOException err) { + throw failed(err); + } + values = new LinkedHashMap(); + firstValues = new LinkedHashMap(); + for (int iter = 0 ; ; iter++) { + String name = connection.getHeaderFieldKey(iter); + String value = connection.getHeaderField(iter); + if (name == null && value == null) { + break; + } + if (name == null) { + continue; + } + String key = asciiLower(name); + List all = (List) values.get(key); + if (all == null) { + all = new ArrayList(); + values.put(key, all); + firstValues.put(key, value); + } + all.add(value); + } + } + + private void requireOpen() throws IOException { + if (closed) { + throw new IOException("this exchange has been closed"); + } + } + + private IOException failed(IOException cause) { + return new IOException("Request to " + Urls.forMessage(url) + " failed: " + + cause.getMessage(), cause); + } + } } diff --git a/vm/backend/impl/parparvm/com/codename1/backend/FileIo.java b/vm/backend/impl/parparvm/com/codename1/backend/FileIo.java index e59e9e0d60f..2951e4f2386 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/FileIo.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/FileIo.java @@ -156,6 +156,33 @@ private static void checkRange(byte[] buffer, int offset, int length) { } } + /// A new spool file in `dir` (null for the system temporary directory), + /// already unlinked, so closing the descriptor is all the cleanup it needs. + /// The descriptor, or -1. + public static int createTemp(String dir) { + if (holdsNul(dir)) { + return -1; + } + return createTempImpl(dir); + } + + /// Writes the whole slice at the file's position; false on any failure. + public static boolean write(int fd, byte[] buffer, int offset, int length) { + checkRange(buffer, offset, length); + return writeImpl(fd, buffer, offset, length) == length; + } + + /// Reads at `position` without moving the file's own position. The count, + /// 0 at the end, or -1. + public static int pread(int fd, byte[] buffer, int offset, int length, long position) { + checkRange(buffer, offset, length); + return preadImpl(fd, buffer, offset, length, position); + } + + private static native int createTempImpl(String dir); + private static native int writeImpl(int fd, byte[] buffer, int offset, int length); + private static native int preadImpl(int fd, byte[] buffer, int offset, int length, + long position); private static native int readImpl(int fd, byte[] buffer, int offset, int length); private static native String realPathImpl(String path); private static native void closeImpl(int fd); diff --git a/vm/backend/impl/parparvm/com/codename1/backend/Http2.java b/vm/backend/impl/parparvm/com/codename1/backend/Http2.java index e4a544eea5c..db40500ed32 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/Http2.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/Http2.java @@ -65,6 +65,8 @@ public static final class Stream { final String authority; final Map headers; final byte[] body; + private Http2 owner; + private long bodyLength; Stream(int id, String method, String path, String authority, Map headers, byte[] body) { this.id = id; @@ -73,6 +75,7 @@ public static final class Stream { this.authority = authority; this.headers = headers; this.body = body; + this.bodyLength = body == null ? 0 : body.length; } public int getId() { @@ -99,10 +102,27 @@ public Map getHeaders() { } /// The body as it ARRIVED, so the caller can check it before decoding. + /// Empty when the body was spooled; see [#takeBodyFd]. public byte[] getBody() { return body; } + /// The descriptor of a body that went to disk on arrival (an unlinked + /// temporary file), or -1 when it is in [#getBody]. Handed over once: + /// the caller owns closing it, and a second call answers -1. Must be + /// called before the next [Http2#nextRequest], which frees an unclaimed + /// one. + public int takeBodyFd() { + Http2 from = owner; + owner = null; + return from == null || from.session == 0 ? -1 : bodyFdImpl(from.session); + } + + /// The body's whole length, wherever it is. + public long getBodyLength() { + return bodyLength; + } + public String getBodyAsString() { if (body == null || body.length == 0) { return null; @@ -152,8 +172,17 @@ public Stream nextRequest() { } } } - return new Stream(id, methodImpl(session), pathImpl(session), + Stream stream = new Stream(id, methodImpl(session), pathImpl(session), authorityImpl(session), headers, bodyImpl(session)); + long length = bodyLengthImpl(session); + if (length > stream.bodyLength) { + // Spooled. The descriptor stays the session's -- freed with the + // request at the next nextRequest() -- until takeBodyFd claims it, + // so a stream refused before its body is looked at leaks nothing. + stream.owner = this; + stream.bodyLength = length; + } + return stream; } /// - `extraHeaders`: "name: value" strings. Connection-specific headers are @@ -186,6 +215,13 @@ public boolean respond(int streamId, int status, String contentType, List extraH /// The ceiling for outstanding response bodies across the process. /// /// Set once, and enforced natively where the memory is actually taken. + /// The request-body rules HttpServer applies to HTTP/1, for this protocol + /// too: the largest body taken, and the size past which it is written to an + /// unlinked temporary file in `dir` (null for the system's) as it arrives. + public static void setBodyLimits(long limit, int spoolThreshold, String dir) { + setBodyLimitsImpl(limit, spoolThreshold, dir); + } + public static void setMaxBodyBytes(long limit) { setMaxBodyBytesImpl(limit); } @@ -355,6 +391,9 @@ private static void checkRange(byte[] buffer, int offset, int length) { private static native String headerNameImpl(long session, int index); private static native String headerValueImpl(long session, int index); private static native byte[] bodyImpl(long session); + private static native int bodyFdImpl(long session); + private static native long bodyLengthImpl(long session); + private static native void setBodyLimitsImpl(long limit, int threshold, String dir); /// The header block both response forms send, as "name: value" lines. /// /// BYTES, one per character, and not a String for the native to encode. A diff --git a/vm/backend/impl/parparvm/com/codename1/backend/Web.java b/vm/backend/impl/parparvm/com/codename1/backend/Web.java index 35a0f77feb7..d04275c07ef 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/Web.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/Web.java @@ -23,6 +23,8 @@ package com.codename1.backend; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -380,14 +382,317 @@ private static Result perform(WebClient client, String method, String url, List private static final int FLAG_CLIENT = 2; /// Bit 2: never keep or reuse a connection. private static final int FLAG_NO_REUSE = 4; + /// Bit 3: a streamed exchange, stepped from Java; see Exchange. + private static final int FLAG_STREAM = 8; + + private static final int WANT_HEADERS = 1; + private static final int WANT_DATA = 2; + private static final int WANT_SPACE = 3; + + /// The request has no body. + public static final long NO_BODY = -2; + /// The request has a body whose length is not known in advance: chunked on + /// HTTP/1.1, DATA frames until the end on HTTP/2. + public static final long UNKNOWN_LENGTH = -1; + + /// Opens a streamed exchange; see [Exchange]. Every default, no pool. + public static Exchange open(String method, String url, List headers, long requestLength) + throws IOException { + return open(null, method, url, headers, requestLength); + } + + static Exchange open(WebClient client, String method, String url, List headers, + long requestLength) throws IOException { + Urls.requireHttp(url); + HeaderLines.requireMethod(method); + HeaderLines.validate(headers); + if (requestLength < NO_BODY) { + throw new IllegalArgumentException("requestLength " + requestLength); + } + StringBuilder joined = new StringBuilder(); + if (headers != null) { + for (int iter = 0 ; iter < headers.size() ; iter++) { + if (iter > 0) { + joined.append('\n'); + } + joined.append(String.valueOf(headers.get(iter))); + } + } + initialiseCurlOnce(); + // callerHeaders true: redirects are never followed in a stream anyway, + // and it keeps the follow decision out of the transfer entirely. + long handle = performClient(client, 0, method, url, + HeaderLines.narrowed(joined.toString()), null, true, FLAG_STREAM, requestLength); + if (handle == 0) { + throw new IOException("Could not start a request to " + Urls.forMessage(url)); + } + if (statusImpl(handle) == -1 && errorImpl(handle) != null) { + String error = errorImpl(handle); + freeImpl(handle); + throw new IOException("Request to " + Urls.forMessage(url) + " failed: " + error); + } + return new Exchange(client, handle, url, requestLength != NO_BODY); + } + + /// One streamed HTTP exchange: the request body written as it is produced, + /// the response read as it arrives, neither held in memory whole. The way to + /// move a large upload or download, or to forward one (see HttpProxy). + /// + /// Use it from one thread, in order: write and close [#getRequestBody] when + /// the request has one, then [#getStatus] and the headers, then read + /// [#getResponseBody]. Writes block until the transport has taken the bytes + /// and reads until some have arrived, so a slow peer at either end slows the + /// producer rather than filling memory -- each direction buffers a fixed + /// 256 KiB window. [#close] ends the exchange whatever state it is in; + /// before the response has been read to its end that CANCELS it, closing the + /// upstream connection rather than draining it. Always close it, in a finally. + /// + /// Redirects are never followed here: a streamed body cannot be replayed. + /// The request timeout of a WebClient does not apply either -- a stream may + /// legitimately run for as long as it moves -- while its connect and read + /// timeouts do, the read timeout bounding each wait for the peer. A stream + /// has a connection of its own rather than one from the client's pool. + public static final class Exchange { + private final WebClient client; + private final String url; + private final boolean hasBody; + private long handle; + private boolean requestDone; + private boolean headersRead; + private boolean ended; + private int status = -1; + private String protocol; + private Map values; + private Map firstValues; + private OutputStream requestBody; + private InputStream responseBody; + + Exchange(WebClient client, long handle, String url, boolean hasBody) { + this.client = client; + this.handle = handle; + this.url = url; + this.hasBody = hasBody; + this.requestDone = !hasBody; + } + + /// Where the request body goes. Closing it ends the request body; the + /// stream is the exchange's and closing it does not end the exchange. + public OutputStream getRequestBody() throws IOException { + if (!hasBody) { + throw new IllegalStateException("this exchange was opened with NO_BODY"); + } + requireOpen(); + if (requestBody == null) { + requestBody = new OutputStream() { + @Override + public void write(int b) throws IOException { + write(new byte[] {(byte) b}, 0, 1); + } + + @Override + public void write(byte[] data, int offset, int length) throws IOException { + if (requestDone) { + throw new IOException("the request body has been closed"); + } + if (offset < 0 || length < 0 || length > data.length - offset) { + throw new IndexOutOfBoundsException(); + } + int at = offset; + int left = length; + while (left > 0) { + requireOpen(); + int taken = streamWriteImpl(handle, data, at, left); + if (taken < 0) { + throw new IOException("Out of memory for the request body to " + + Urls.forMessage(url)); + } + // Returns once libcurl has taken the whole block: the + // backpressure, and what makes reusing `data` safe. + int state = streamStepImpl(handle, WANT_SPACE); + if ((state & 2) != 0) { + ended = true; + if (statusImpl(handle) < 0) { + throw failure(); + } + throw new IOException("Request to " + Urls.forMessage(url) + + " ended before its body was sent"); + } + at += taken; + left -= taken; + } + } + + @Override + public void close() throws IOException { + if (!requestDone) { + requestDone = true; + requireOpen(); + streamEndUploadImpl(handle); + } + } + }; + } + return requestBody; + } + + /// The response status, waiting for the response headers. Ends a request + /// body that is still open first. + public int getStatus() throws IOException { + awaitResponse(); + return status; + } + + /// "HTTP/1.1" or "HTTP/2", as negotiated. + public String getProtocol() throws IOException { + awaitResponse(); + return protocol; + } + + /// The first value of every response header, by lower-case name. + public Map getHeaders() throws IOException { + awaitResponse(); + return firstValues; + } + + /// Every response header, by lower-case name, each a List of values in + /// arrival order -- Set-Cookie and the like keep every line. + public Map getHeaderValueMap() throws IOException { + awaitResponse(); + return values; + } + + public String getHeader(String name) throws IOException { + List all = getHeaderValues(name); + return all.isEmpty() ? null : (String) all.get(0); + } + + public List getHeaderValues(String name) throws IOException { + awaitResponse(); + List all = name == null ? null : (List) values.get(asciiLower(name)); + return all == null ? new ArrayList() : all; + } + + /// The response body as it arrives. Reading it to the end completes the + /// exchange; [#close] before that cancels it. + public InputStream getResponseBody() throws IOException { + awaitResponse(); + if (responseBody == null) { + responseBody = new InputStream() { + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n <= 0 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] target, int offset, int length) throws IOException { + if (offset < 0 || length < 0 || length > target.length - offset) { + throw new IndexOutOfBoundsException(); + } + if (length == 0) { + return 0; + } + while (true) { + requireOpen(); + int n = streamReadImpl(handle, target, offset, length); + if (n > 0) { + return n; + } + if (ended) { + return -1; + } + int state = streamStepImpl(handle, WANT_DATA); + if ((state & 2) != 0) { + ended = true; + if (statusImpl(handle) < 0) { + throw failure(); + } + } + } + } + + @Override + public void close() { + Exchange.this.close(); + } + }; + } + return responseBody; + } + + /// Ends the exchange. Before the response body has been read to its end + /// this cancels it: the transfer is aborted and its connection closed. + public void close() { + if (handle == 0) { + return; + } + long release = handle; + handle = 0; + freeImpl(release); + if (client != null) { + client.exchangeEnded(); + } + } + + private void awaitResponse() throws IOException { + requireOpen(); + if (headersRead) { + return; + } + if (!requestDone) { + getRequestBody().close(); + } + int state = streamStepImpl(handle, WANT_HEADERS); + if ((state & 2) != 0) { + ended = true; + } + status = statusImpl(handle); + if (status < 0) { + throw failure(); + } + values = parseHeaders(headersImpl(handle)); + firstValues = new LinkedHashMap(); + java.util.Iterator it = values.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = (Map.Entry) it.next(); + List all = (List) entry.getValue(); + if (all != null && !all.isEmpty()) { + firstValues.put(entry.getKey(), all.get(0)); + } + } + protocol = protocolName(protocolImpl(handle)); + headersRead = true; + } + + private void requireOpen() throws IOException { + if (handle == 0) { + throw new IOException("this exchange has been closed"); + } + } + + private IOException failure() { + String error = errorImpl(handle); + return new IOException("Request to " + Urls.forMessage(url) + " failed: " + + (error == null ? "unknown error" : error)); + } + } private static long performClient(WebClient client, long pool, String method, String url, byte[] headerLines, byte[] body, boolean callerHeaders) { + return performClient(client, pool, method, url, headerLines, body, callerHeaders, 0, + NO_BODY); + } + + private static long performClient(WebClient client, long pool, String method, String url, + byte[] headerLines, byte[] body, boolean callerHeaders, + int extraFlags, long uploadLength) { + boolean streamed = (extraFlags & FLAG_STREAM) != 0; if (client == null) { - return performImpl(0, method, url, headerLines, body, callerHeaders, 0, - 30000, 30000, 0, null, null, -1, null); + return performImpl(0, method, url, headerLines, body, callerHeaders, extraFlags, + 30000, 30000, 0, null, null, -1, null, uploadLength); } - int flags = FLAG_CLIENT; + int flags = FLAG_CLIENT | extraFlags; if (client.isRequireHttp2()) { flags |= FLAG_REQUIRE_HTTP2; } @@ -405,9 +710,12 @@ private static long performClient(WebClient client, long pool, String method, St type = credential.getType(); password = credential.getPassword(); } + // A stream has no whole-request deadline: it may run for as long as it + // moves, which the read timeout (LOW_SPEED_TIME) still bounds. return performImpl(pool, method, url, headerLines, body, callerHeaders, flags, client.getConnectTimeoutMillis(), client.getReadTimeoutMillis(), - client.getRequestTimeoutMillis(), certificate, key, type, password); + streamed ? 0 : client.getRequestTimeoutMillis(), certificate, key, type, + password, uploadLength); } /// CURLINFO_HTTP_VERSION's answer as a protocol name. @@ -523,7 +831,12 @@ private static native long performImpl(long pool, String method, String url, boolean callerHeaders, int flags, int connectTimeoutMillis, int readTimeoutMillis, int requestTimeoutMillis, byte[] certificate, - byte[] key, int certificateType, String password); + byte[] key, int certificateType, String password, + long uploadLength); + private static native int streamStepImpl(long handle, int want); + private static native int streamReadImpl(long handle, byte[] buffer, int offset, int length); + private static native int streamWriteImpl(long handle, byte[] buffer, int offset, int length); + private static native void streamEndUploadImpl(long handle); private static native long poolCreateImpl(); private static native void poolFreeImpl(long pool); private static native int protocolImpl(long handle); diff --git a/vm/backend/native/cn1_backend_files.c b/vm/backend/native/cn1_backend_files.c index 0be1a7e9ee1..7981546dfdb 100644 --- a/vm/backend/native/cn1_backend_files.c +++ b/vm/backend/native/cn1_backend_files.c @@ -42,6 +42,7 @@ */ #include "cn1_globals.h" #include +#include #include #include /* PATH_MAX, for the component walk below */ #include @@ -480,3 +481,88 @@ JAVA_VOID com_codename1_backend_FileIo_closeImpl___int(CODENAME_ONE_THREAD_STATE (void)fd; #endif } + +/* + * Spool files: the disk side of a request body or multipart part too large to + * hold in memory (Spool.java). The file is unlinked the moment it is created, + * so it has no name for anything else to open and the kernel reclaims it when + * the descriptor closes -- on the normal path, on a handler that throws, on a + * dropped connection, and on a process that dies, none of which has to find a + * path to delete. Owner-only either way: mkstemp creates it 0600. + */ +JAVA_INT com_codename1_backend_FileIo_createTempImpl___java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT dir) { +#ifdef _WIN32 + (void)dir; + return -1; +#else + const char* d = dir == JAVA_NULL ? NULL : stringToUTF8(threadStateData, dir); + char path[PATH_MAX]; + int fd; + if(d == NULL || d[0] == 0) { + d = getenv("TMPDIR"); + } + if(d == NULL || d[0] == 0) { + d = "/tmp"; + } + if(snprintf(path, sizeof(path), "%s/cn1-spool-XXXXXX", d) >= (int)sizeof(path)) { + return -1; + } + fd = mkstemp(path); + if(fd < 0) { + return -1; + } + unlink(path); + fcntl(fd, F_SETFD, FD_CLOEXEC); + return fd; +#endif +} + +/* Writes all of the slice or fails: -1 on error, otherwise length. */ +JAVA_INT com_codename1_backend_FileIo_writeImpl___int_byte_1ARRAY_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_INT fd, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length) { +#ifdef _WIN32 + (void)fd; (void)buffer; (void)offset; (void)length; + return -1; +#else + JAVA_ARRAY_BYTE* data; + size_t done = 0; + if(fd < 0 || buffer == JAVA_NULL) { + return -1; + } + data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); + CN1_YIELD_THREAD; + while(done < (size_t)length) { + ssize_t n = write(fd, &data[offset + done], (size_t)length - done); + if(n < 0 && errno == EINTR) { + continue; + } + if(n <= 0) { + CN1_RESUME_THREAD; + return -1; + } + done += (size_t)n; + } + CN1_RESUME_THREAD; + return length; +#endif +} + +/* A positioned read, so any number of streams can read one spool file. */ +JAVA_INT com_codename1_backend_FileIo_preadImpl___int_byte_1ARRAY_int_int_long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_INT fd, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length, JAVA_LONG position) { +#ifdef _WIN32 + (void)fd; (void)buffer; (void)offset; (void)length; (void)position; + return -1; +#else + JAVA_ARRAY_BYTE* data; + ssize_t n; + if(fd < 0 || buffer == JAVA_NULL || position < 0) { + return -1; + } + data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); + CN1_YIELD_THREAD; + do { + n = pread(fd, &data[offset], (size_t)length, (off_t)position); + } while(n < 0 && errno == EINTR); + CN1_RESUME_THREAD; + return n < 0 ? -1 : (JAVA_INT)n; +#endif +} diff --git a/vm/backend/native/cn1_backend_http2.c b/vm/backend/native/cn1_backend_http2.c index ac4cb3ab707..33662b7e271 100644 --- a/vm/backend/native/cn1_backend_http2.c +++ b/vm/backend/native/cn1_backend_http2.c @@ -44,6 +44,7 @@ #include #include #include +#include #include #define CN1_H2_MAX_HEADERS 64 @@ -69,6 +70,15 @@ HTTP/2 must agree, or the limit is only as good as the protocol chosen. */ #define CN1_H2_MAX_BODY_BYTES (8 * 1024 * 1024) +/* Set from HttpServer through setBodyLimitsImpl, so the two protocols take the + same upload: the largest body accepted at all, and the size past which it + goes to disk rather than the heap. The in-memory ceilings above then bound + only what is in memory. Defaults are the HTTP/1 side's own defaults. */ +static _Atomic long cn1H2BodyLimit = CN1_H2_MAX_BODY_BYTES; +static _Atomic long cn1H2SpoolThreshold = 1024 * 1024; +static char cn1H2SpoolDir[1024]; +static _Atomic int cn1H2SpoolDirSet = 0; + typedef struct CN1H2Header { char* name; char* value; @@ -86,7 +96,20 @@ typedef struct CN1H2Request { unsigned char* body; size_t bodyLength; size_t bodyCapacity; + /* A body past the spool threshold lives in an unlinked temporary file + instead of `body` (spooled set, spoolFd its descriptor until Java takes + it with bodyFdImpl, -1 after). bodyLength still counts all of it. */ + int spooled; + int spoolFd; int complete; + /* A DATA chunk of this stream was refused, so nghttp2 is resetting it. The + peer's frames already in flight are still delivered, the last one with + END_STREAM, and that must not complete a request missing part of its + body: the handler would answer for an upload it never received whole. */ + int refused; + /* The largest body this stream may carry, decided from its headers on its + first DATA chunk (0 until then); see cn1H2BodyCap. */ + long bodyCap; struct CN1H2Request* next; } CN1H2Request; @@ -348,6 +371,9 @@ static void cn1H2FreeRequest(CN1H2Request* r) { free(r->headers[i].value); } free(r->body); + if(r->spooled && r->spoolFd >= 0) { + close(r->spoolFd); + } free(r); } @@ -578,9 +604,94 @@ static int cn1H2OnHeader(nghttp2_session* session, const nghttp2_frame* frame, return 0; } +/* Writes all of `length` or fails. */ +static int cn1H2WriteAll(int fd, const uint8_t* data, size_t length) { + size_t done = 0; + while(done < length) { + ssize_t n = write(fd, data + done, length - done); + if(n < 0 && errno == EINTR) { + continue; + } + if(n <= 0) { + return 0; + } + done += (size_t)n; + } + return 1; +} + +/* Moves a request's body from the heap to a fresh unlinked temporary file, + which the kernel reclaims when the descriptor closes however the request + ends. */ +static int cn1H2Spill(CN1H2Request* r) { + char path[1100]; + const char* dir = NULL; + int fd; + if(atomic_load_explicit(&cn1H2SpoolDirSet, memory_order_acquire) && cn1H2SpoolDir[0] != 0) { + dir = cn1H2SpoolDir; + } + if(dir == NULL) { + dir = getenv("TMPDIR"); + } + if(dir == NULL || dir[0] == 0) { + dir = "/tmp"; + } + if(snprintf(path, sizeof(path), "%s/cn1-spool-XXXXXX", dir) >= (int)sizeof(path)) { + return 0; + } + fd = mkstemp(path); + if(fd < 0) { + return 0; + } + unlink(path); + fcntl(fd, F_SETFD, FD_CLOEXEC); + if(r->bodyLength > 0 && !cn1H2WriteAll(fd, r->body, r->bodyLength)) { + close(fd); + return 0; + } + atomic_fetch_sub_explicit(&cn1H2InboundBytes, (long)r->bodyCapacity, memory_order_relaxed); + free(r->body); + r->body = NULL; + r->bodyCapacity = 0; + r->spooled = 1; + r->spoolFd = fd; + return 1; +} + +static int cn1H2OnDataChunk(nghttp2_session* session, uint8_t flags, int32_t streamId, + const uint8_t* data, size_t length, void* userData); +static long cn1H2BodyCap(CN1H2Request* r); +/* Only its address is used: the stream user data that marks a refused upload. */ +static char cn1H2ResetAfterAnswer; +static void cn1H2RefuseTooLarge(CN1H2Session* s, CN1H2Request* r); + static int cn1H2OnData(nghttp2_session* session, uint8_t flags, int32_t streamId, const uint8_t* data, size_t length, void* userData) { CN1H2Session* s = (CN1H2Session*)userData; + CN1H2Request* open = cn1H2FindOpen(s, streamId); + int result; + /* Too large is answered, as HTTP/1 answers it: a 413 the client can read, + then RST_STREAM(NO_ERROR) so it stops sending (RFC 9113 8.1). Only a + plain reset used to follow, which an uploader reports as a protocol + error rather than the limit it ran into. */ + if(open != NULL && !open->refused + && (long)(open->bodyLength + length) > cn1H2BodyCap(open)) { + cn1H2RefuseTooLarge(s, open); + return 0; + } + result = cn1H2OnDataChunk(session, flags, streamId, data, length, userData); + if(result != 0) { + CN1H2Request* r = cn1H2FindOpen((CN1H2Session*)userData, streamId); + if(r != NULL) { + r->refused = 1; + } + } + return result; +} + +static int cn1H2OnDataChunk(nghttp2_session* session, uint8_t flags, int32_t streamId, + const uint8_t* data, size_t length, void* userData) { + CN1H2Session* s = (CN1H2Session*)userData; CN1H2Request* r = cn1H2FindOpen(s, streamId); (void)session; (void)flags; @@ -593,9 +704,25 @@ static int cn1H2OnData(nghttp2_session* session, uint8_t flags, int32_t streamId which the HTTP/1 side simply does not permit. Resetting the stream rather than failing the callback keeps the connection and its other streams alive: one oversized upload is that request's problem, not the session's. */ - if(r->bodyLength + length > CN1_H2_MAX_BODY_BYTES) { + if((long)(r->bodyLength + length) > atomic_load_explicit(&cn1H2BodyLimit, memory_order_relaxed)) { return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; } + if(!r->spooled && (long)(r->bodyLength + length) + > atomic_load_explicit(&cn1H2SpoolThreshold, memory_order_relaxed)) { + /* Past the threshold the body goes to disk, where it costs no heap and + none of the in-memory ceilings below. A file that cannot be made is + this stream's failure, as running out of buffer would be. */ + if(!cn1H2Spill(r)) { + return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; + } + } + if(r->spooled) { + if(!cn1H2WriteAll(r->spoolFd, data, length)) { + return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; + } + r->bodyLength += length; + return 0; + } { /* Walked rather than counted in a running total: the total would have to be decremented everywhere a request is freed, and one missed path @@ -606,13 +733,14 @@ static int cn1H2OnData(nghttp2_session* session, uint8_t flags, int32_t streamId the sum and only the new bytes are added. */ size_t total = length; CN1H2Request* other = s->open; + /* Heap only: a spooled body is on disk and bounded by its own limit. */ while(other != NULL) { - total += other->bodyLength; + total += other->spooled ? 0 : other->bodyLength; other = other->next; } other = s->readyHead; while(other != NULL) { - total += other->bodyLength; + total += other->spooled ? 0 : other->bodyLength; other = other->next; } if(total > CN1_H2_MAX_SESSION_BODY_BYTES) { @@ -642,8 +770,15 @@ static int cn1H2OnData(nghttp2_session* session, uint8_t flags, int32_t streamId size_t grown = (r->bodyLength + length) * 2 + 1024; size_t added; unsigned char* buf; - if(grown > CN1_H2_MAX_BODY_BYTES) { - grown = CN1_H2_MAX_BODY_BYTES; + size_t cap = (size_t)atomic_load_explicit(&cn1H2SpoolThreshold, memory_order_relaxed); + if(cap > CN1_H2_MAX_BODY_BYTES) { + cap = CN1_H2_MAX_BODY_BYTES; + } + if(grown > cap) { + grown = cap; + } + if(grown < r->bodyLength + length) { + grown = r->bodyLength + length; } added = grown - r->bodyCapacity; if(!cn1H2ReserveInboundBytes((long)added)) { @@ -685,6 +820,116 @@ static int cn1H2EqualsLower(const char* at, size_t length, const char* lower) { return lower[length] == 0 ? 1 : 0; } +/* Whether an ASCII value ends with `lower` (given in lower case), ignoring case. */ +static int cn1H2EndsWithLower(const char* value, size_t length, const char* lower) { + size_t n = strlen(lower); + return length >= n && cn1H2EqualsLower(value + length - n, n, lower); +} + +/* + * HttpServer.isSpoolable, for the headers of one stream: a binary content type + * sent identity encoded. The rule is Java's and lives there; this is the same + * rule where it has to be applied, as the body arrives, so that a text or + * compressed body is refused at the in-memory limit HTTP/1 holds it to rather + * than written to disk up to the far larger spooled one and refused after. + */ +static int cn1H2Spoolable(CN1H2Request* r) { + const char* type = NULL; + const char* coding = NULL; + size_t iter; + size_t length; + for(iter = 0 ; iter < (size_t)r->headerCount ; iter++) { + if(r->headers[iter].name == NULL || r->headers[iter].value == NULL) { + continue; + } + if(strcmp(r->headers[iter].name, "content-type") == 0) { + type = r->headers[iter].value; + } else if(strcmp(r->headers[iter].name, "content-encoding") == 0) { + coding = r->headers[iter].value; + } + } + if(coding != NULL) { + while(*coding == ' ' || *coding == '\t') { + coding++; + } + length = strlen(coding); + while(length > 0 && (coding[length - 1] == ' ' || coding[length - 1] == '\t')) { + length--; + } + if(length != 0 && !(length == 8 && cn1H2EqualsLower(coding, 8, "identity"))) { + return 0; + } + } + if(type == NULL) { + return 0; + } + while(*type == ' ' || *type == '\t') { + type++; + } + { + const char* semi = strchr(type, ';'); + length = semi == NULL ? strlen(type) : (size_t)(semi - type); + } + while(length > 0 && (type[length - 1] == ' ' || type[length - 1] == '\t')) { + length--; + } + if(length == 0 || (length >= 5 && cn1H2EqualsLower(type, 5, "text/"))) { + return 0; + } + if((length == 16 && cn1H2EqualsLower(type, 16, "application/json")) + || (length == 15 && cn1H2EqualsLower(type, 15, "application/xml")) + || (length == 33 && cn1H2EqualsLower(type, 33, "application/x-www-form-urlencoded")) + || (length == 22 && cn1H2EqualsLower(type, 22, "application/javascript")) + || (length == 19 && cn1H2EqualsLower(type, 19, "application/graphql"))) { + return 0; + } + return !(cn1H2EndsWithLower(type, length, "+json") || cn1H2EndsWithLower(type, length, "+xml")); +} + +/* The spooled limit for a body that may be spooled, the in-memory one for any + other -- the two limits HTTP/1 applies, chosen by the same rule. */ +static long cn1H2BodyCap(CN1H2Request* r) { + if(r->bodyCap == 0) { + long limit = atomic_load_explicit(&cn1H2BodyLimit, memory_order_relaxed); + r->bodyCap = cn1H2Spoolable(r) || limit < CN1_H2_MAX_BODY_BYTES + ? limit : CN1_H2_MAX_BODY_BYTES; + } + return r->bodyCap; +} + +/* Answers 413 and stops the upload; the request never reaches Java. */ +static void cn1H2RefuseTooLarge(CN1H2Session* s, CN1H2Request* r) { + nghttp2_nv tooLarge[1]; + tooLarge[0].name = (uint8_t*)":status"; + tooLarge[0].namelen = 7; + tooLarge[0].value = (uint8_t*)"413"; + tooLarge[0].valuelen = 3; + tooLarge[0].flags = NGHTTP2_NV_FLAG_NONE; + nghttp2_submit_response(s->session, r->streamId, tooLarge, 1, NULL); + /* The reset waits for the 413 to be on the wire (cn1H2OnFrameSend): nghttp2 + sends a queued RST_STREAM ahead of queued HEADERS, so submitting both here + closed the stream before the client ever saw a status. */ + nghttp2_session_set_stream_user_data(s->session, r->streamId, &cn1H2ResetAfterAnswer); + cn1H2Unlink(&s->open, r); + cn1H2FreeRequest(r); +} + +/* After a 413 for an upload still in progress, tell the client to stop + sending (RFC 9113 8.1): the answer is complete and the rest is unwanted. */ +static int cn1H2OnFrameSend(nghttp2_session* session, const nghttp2_frame* frame, + void* userData) { + (void)userData; + if(frame->hd.type == NGHTTP2_HEADERS + && (frame->hd.flags & NGHTTP2_FLAG_END_STREAM) != 0 + && nghttp2_session_get_stream_user_data(session, frame->hd.stream_id) + == &cn1H2ResetAfterAnswer) { + nghttp2_session_set_stream_user_data(session, frame->hd.stream_id, NULL); + nghttp2_submit_rst_stream(session, NGHTTP2_FLAG_NONE, frame->hd.stream_id, + NGHTTP2_NO_ERROR); + } + return 0; +} + /* * Whether every token of an Expect field is 100-continue, which is the only * expectation this server can make true. @@ -787,16 +1032,10 @@ static int cn1H2ResolveExpect(CN1H2Session* s, CN1H2Request* r, int bodyToCome) /* Only a length this server can read is judged here. A malformed one is nghttp2's to refuse, and an unreadable value must not be turned into a refusal of its own by this loop. */ - if(end != NULL && *end == '\0' && declared > CN1_H2_MAX_BODY_BYTES) { - nghttp2_nv tooLarge[1]; - tooLarge[0].name = (uint8_t*)":status"; - tooLarge[0].namelen = 7; - tooLarge[0].value = (uint8_t*)"413"; - tooLarge[0].valuelen = 3; - tooLarge[0].flags = NGHTTP2_NV_FLAG_NONE; - nghttp2_submit_response(s->session, r->streamId, tooLarge, 1, NULL); - cn1H2Unlink(&s->open, r); - cn1H2FreeRequest(r); + if(end != NULL && *end == '\0' && declared > cn1H2BodyCap(r)) { + /* Answered, then reset once the 413 is out, so a client that + sends anyway after its wait is told to stop. */ + cn1H2RefuseTooLarge(s, r); return 1; } } @@ -847,8 +1086,8 @@ static int cn1H2OnFrameRecv(nghttp2_session* session, const nghttp2_frame* frame return 0; } r = cn1H2FindOpen(s, frame->hd.stream_id); - if(r == NULL) { - return 0; + if(r == NULL || r->refused) { + return 0; /* a refused stream is freed when its reset closes it */ } /* BEFORE the END_STREAM test below, because a request that is waiting to be told to send its body has not set it. */ @@ -953,6 +1192,7 @@ JAVA_LONG com_codename1_backend_Http2_createImpl___R_long(CODENAME_ONE_THREAD_ST nghttp2_session_callbacks_set_on_data_chunk_recv_callback(callbacks, cn1H2OnData); nghttp2_session_callbacks_set_on_frame_recv_callback(callbacks, cn1H2OnFrameRecv); nghttp2_session_callbacks_set_on_stream_close_callback(callbacks, cn1H2OnStreamClose); + nghttp2_session_callbacks_set_on_frame_send_callback(callbacks, cn1H2OnFrameSend); if(nghttp2_session_server_new(&s->session, callbacks, s) != 0) { nghttp2_session_callbacks_del(callbacks); @@ -1210,6 +1450,42 @@ JAVA_OBJECT com_codename1_backend_Http2_headerValueImpl___long_int_R_java_lang_S (int)strlen(s->current->headers[index].value)); } +/* The descriptor of the current request's spooled body, handed to Java, which + closes it from here on; -1 when the body is in memory. */ +JAVA_INT com_codename1_backend_Http2_bodyFdImpl___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + CN1H2Session* s = (CN1H2Session*)(intptr_t)handle; + int fd; + if(s == NULL || s->current == NULL || !s->current->spooled) { + return -1; + } + fd = s->current->spoolFd; + s->current->spoolFd = -1; + return fd; +} + +JAVA_LONG com_codename1_backend_Http2_bodyLengthImpl___long_R_long(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + CN1H2Session* s = (CN1H2Session*)(intptr_t)handle; + if(s == NULL || s->current == NULL) { + return 0; + } + return (JAVA_LONG)s->current->bodyLength; +} + +JAVA_VOID com_codename1_backend_Http2_setBodyLimitsImpl___long_int_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG limit, JAVA_INT threshold, JAVA_OBJECT dir) { + atomic_store_explicit(&cn1H2BodyLimit, (long)limit, memory_order_relaxed); + atomic_store_explicit(&cn1H2SpoolThreshold, (long)threshold, memory_order_relaxed); + /* Written once: the directory comes from the environment and every session + passes the same one, so later calls have nothing to change and must not + rewrite a buffer another worker may be reading. */ + if(dir != JAVA_NULL && !atomic_load_explicit(&cn1H2SpoolDirSet, memory_order_acquire)) { + const char* d = stringToUTF8(threadStateData, dir); + if(d != NULL && strlen(d) < sizeof(cn1H2SpoolDir)) { + strcpy(cn1H2SpoolDir, d); + atomic_store_explicit(&cn1H2SpoolDirSet, 1, memory_order_release); + } + } +} + JAVA_OBJECT com_codename1_backend_Http2_bodyImpl___long_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { CN1H2Session* s = (CN1H2Session*)(intptr_t)handle; JAVA_OBJECT arr; @@ -1217,7 +1493,7 @@ JAVA_OBJECT com_codename1_backend_Http2_bodyImpl___long_R_byte_1ARRAY(CODENAME_O if(s == NULL || s->current == NULL) { return JAVA_NULL; } - length = s->current->bodyLength; + length = s->current->spooled ? 0 : s->current->bodyLength; arr = allocArray(threadStateData, (int)length, &class_array1__JAVA_BYTE, sizeof(JAVA_ARRAY_BYTE), 1); if(length > 0) { memcpy((JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(arr), s->current->body, length); diff --git a/vm/backend/native/cn1_backend_web.c b/vm/backend/native/cn1_backend_web.c index a59e62779ec..e5415aa201a 100644 --- a/vm/backend/native/cn1_backend_web.c +++ b/vm/backend/native/cn1_backend_web.c @@ -130,6 +130,77 @@ static void cn1WebWatchMulti(CURLM* multi, CN1WebWatch* watch) { curl_multi_setopt(multi, CURLMOPT_TIMERDATA, watch); } +/* One wait and one round of socket actions on `multi`: what cn1WebDrive does + * per iteration, and what a streamed transfer (cn1WebStreamStep) does until it + * has what its caller asked for. */ +static void cn1WebTurn(CURLM* multi, CN1WebWatch* w, int park, int* running) { + int fds[CN1_WEB_MAX_SOCKETS]; + int events[CN1_WEB_MAX_SOCKETS]; + struct pollfd probe[CN1_WEB_MAX_SOCKETS]; + int n = w->count; + int i; + int ready = 0; + long long timeout = (long long)w->timeoutMillis; + if(timeout == 0) { + w->timeoutMillis = -1; + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, running); + return; + } + if(n == 0 && timeout < 0) { + /* Nothing to watch and no timer, which libcurl should never leave + us with while a transfer runs; look again shortly rather than + waiting for ever on nothing. */ + timeout = 10; + } + for(i = 0 ; i < n ; i++) { + fds[i] = (int)w->fd[i]; + events[i] = ((w->what[i] & CURL_POLL_IN) ? 1 : 0) + | ((w->what[i] & CURL_POLL_OUT) ? 2 : 0); + probe[i].fd = fds[i]; + probe[i].events = (short)(((events[i] & 1) ? POLLIN : 0) + | ((events[i] & 2) ? POLLOUT : 0)); + probe[i].revents = 0; + } + /* Ask first, as every other wait here does: a socket that is already + ready costs no park. */ + if(n > 0) { + ready = poll(probe, (nfds_t)n, 0); + } + if(ready <= 0 && !park) { + /* Off a virtual thread: block this thread, as curl_easy_perform + would. Nothing to watch means only a timer, so sleep it out in + poll() with no descriptors. */ + ready = poll(probe, (nfds_t)n, timeout > 1000 ? 1000 : (int)timeout); + } else if(ready <= 0) { + cn1BackendVtWait(n, fds, events, timeout); + if(n > 0) { + for(i = 0 ; i < n ; i++) { + probe[i].revents = 0; + } + ready = poll(probe, (nfds_t)n, 0); + } + } + if(ready > 0) { + for(i = 0 ; i < n ; i++) { + int mask = 0; + if(probe[i].revents & (POLLIN | POLLHUP)) { + mask |= CURL_CSELECT_IN; + } + if(probe[i].revents & POLLOUT) { + mask |= CURL_CSELECT_OUT; + } + if(probe[i].revents & (POLLERR | POLLNVAL)) { + mask |= CURL_CSELECT_ERR; + } + if(mask != 0) { + curl_multi_socket_action(multi, (curl_socket_t)fds[i], mask, running); + } + } + } else { + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, running); + } +} + /* Drives one transfer on `multi`, whose socket and timer callbacks report into * `watch` (cn1WebWatchMulti). With `park` the waits park the virtual thread; * without it they block this thread in poll(), which is what a WebClient's @@ -166,71 +237,7 @@ static CURLcode cn1WebDrive(CURLM* pooled, CN1WebWatch* pooledWatch, CURL* curl, } curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &running); while(running) { - int fds[CN1_WEB_MAX_SOCKETS]; - int events[CN1_WEB_MAX_SOCKETS]; - struct pollfd probe[CN1_WEB_MAX_SOCKETS]; - int n = w->count; - int i; - int ready = 0; - long long timeout = (long long)w->timeoutMillis; - if(timeout == 0) { - w->timeoutMillis = -1; - curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &running); - continue; - } - if(n == 0 && timeout < 0) { - /* Nothing to watch and no timer, which libcurl should never leave - us with while a transfer runs; look again shortly rather than - waiting for ever on nothing. */ - timeout = 10; - } - for(i = 0 ; i < n ; i++) { - fds[i] = (int)w->fd[i]; - events[i] = ((w->what[i] & CURL_POLL_IN) ? 1 : 0) - | ((w->what[i] & CURL_POLL_OUT) ? 2 : 0); - probe[i].fd = fds[i]; - probe[i].events = (short)(((events[i] & 1) ? POLLIN : 0) - | ((events[i] & 2) ? POLLOUT : 0)); - probe[i].revents = 0; - } - /* Ask first, as every other wait here does: a socket that is already - ready costs no park. */ - if(n > 0) { - ready = poll(probe, (nfds_t)n, 0); - } - if(ready <= 0 && !park) { - /* Off a virtual thread: block this thread, as curl_easy_perform - would. Nothing to watch means only a timer, so sleep it out in - poll() with no descriptors. */ - ready = poll(probe, (nfds_t)n, timeout > 1000 ? 1000 : (int)timeout); - } else if(ready <= 0) { - cn1BackendVtWait(n, fds, events, timeout); - if(n > 0) { - for(i = 0 ; i < n ; i++) { - probe[i].revents = 0; - } - ready = poll(probe, (nfds_t)n, 0); - } - } - if(ready > 0) { - for(i = 0 ; i < n ; i++) { - int mask = 0; - if(probe[i].revents & (POLLIN | POLLHUP)) { - mask |= CURL_CSELECT_IN; - } - if(probe[i].revents & POLLOUT) { - mask |= CURL_CSELECT_OUT; - } - if(probe[i].revents & (POLLERR | POLLNVAL)) { - mask |= CURL_CSELECT_ERR; - } - if(mask != 0) { - curl_multi_socket_action(multi, (curl_socket_t)fds[i], mask, &running); - } - } - } else { - curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &running); - } + cn1WebTurn(multi, w, park, &running); } while((msg = curl_multi_info_read(multi, &left)) != NULL) { if(msg->msg == CURLMSG_DONE) { @@ -257,6 +264,8 @@ typedef struct { /* CURLINFO_HTTP_VERSION: 1 HTTP/1.0, 2 HTTP/1.1, 3 HTTP/2, 30 HTTP/3, 0 unknown. */ long httpVersion; char error[CURL_ERROR_SIZE]; + /* A streamed exchange's state (CN1WebStream), or NULL; freeImpl ends it. */ + void* stream; } CN1WebResponse; /* @@ -651,17 +660,239 @@ static int cn1WebRequireH2Prereq(void* clientp, char* primaryIp, char* localIp, /* Web.performImpl's flags; see Web.FLAG_*. */ #define CN1_WEB_FLAG_REQUIRE_HTTP2 1 +#define CN1_WEB_FLAG_STREAM 8 + +/* + * A streamed exchange (Web.Exchange): the transfer is set up exactly as + * performImpl sets up any other, then stepped from Java instead of driven to + * its end, so neither body is ever held whole. + * + * Both directions move through a fixed window. The write callback appends to + * `in` and PAUSES the receive side once the window is full, so a fast upstream + * waits on the Java reader; the read callback serves `out` and pauses the send + * side when Java has not supplied the next block yet, so a slow producer holds + * the request open rather than ending it. libcurl redelivers whatever it was + * refused on unpause, so a pause loses nothing. No callback ever calls into + * Java: Java calls in, and each call drives the multi until it has what it + * asked for (cn1WebStreamStep). + * + * The transfer has a multi of its own and is never pooled -- it can outlive + * any number of pooled requests -- and never follows redirects, because its + * body cannot be replayed and a caller that follows them has to see each hop. + */ +#define CN1_WEB_STREAM_WINDOW ((size_t)(256 * 1024)) +#ifndef _WIN32 +typedef struct { + CURLM* multi; + CN1WebWatch watch; + CURL* curl; + struct curl_slist* headers; + char* methodCopy; + char* urlCopy; + char* bodyCopy; + char* certCopy; + size_t certLength; + char* keyCopy; + size_t keyLength; + char* passwordCopy; + size_t passwordLength; + CN1WebH2Check h2check; + int flags; + int running; + int done; + CURLcode rc; + int headersReady; + /* The pause state libcurl was last given, as CURLPAUSE_* bits. */ + int paused; + unsigned char* in; + size_t inLength; + size_t inOffset; + unsigned char* out; + size_t outLength; + size_t outOffset; + int outFinished; + CN1WebResponse* response; +} CN1WebStream; + +static size_t cn1WebStreamWrite(void* contents, size_t size, size_t count, void* userp) { + CN1WebStream* st = (CN1WebStream*)userp; + size_t total = size * count; + st->headersReady = 1; + if(st->inLength - st->inOffset >= CN1_WEB_STREAM_WINDOW) { + st->paused |= CURLPAUSE_RECV; + return CURL_WRITEFUNC_PAUSE; + } + if(st->inOffset > 0) { + memmove(st->in, st->in + st->inOffset, st->inLength - st->inOffset); + st->inLength -= st->inOffset; + st->inOffset = 0; + } + if(total > 0) { + unsigned char* grown = (unsigned char*)realloc(st->in, st->inLength + total); + if(grown == NULL) { + return 0; /* aborts the transfer */ + } + st->in = grown; + memcpy(st->in + st->inLength, contents, total); + st->inLength += total; + } + return total; +} + +static size_t cn1WebStreamHeader(void* contents, size_t size, size_t count, void* userp) { + CN1WebStream* st = (CN1WebStream*)userp; + size_t total = size * count; + size_t taken = cn1WebHeader(contents, size, count, st->response); + const char* line = (const char*)contents; + /* The blank line ending a header block. Redirects are never followed here, + so the first final block is the response; a 1xx block is not. */ + if(taken == total && ((total == 2 && line[0] == '\r' && line[1] == '\n') + || (total == 1 && line[0] == '\n'))) { + long code = 0; + curl_easy_getinfo(st->curl, CURLINFO_RESPONSE_CODE, &code); + if(code >= 200) { + st->headersReady = 1; + } + } + return taken; +} + +static size_t cn1WebStreamRead(char* buffer, size_t size, size_t count, void* userp) { + CN1WebStream* st = (CN1WebStream*)userp; + size_t room = size * count; + if(st->outOffset < st->outLength) { + size_t n = st->outLength - st->outOffset; + if(n > room) { + n = room; + } + memcpy(buffer, st->out + st->outOffset, n); + st->outOffset += n; + return n; + } + if(st->outFinished) { + return 0; + } + st->paused |= CURLPAUSE_SEND; + return CURL_READFUNC_PAUSE; +} + +#define CN1_WEB_WANT_HEADERS 1 +#define CN1_WEB_WANT_DATA 2 +#define CN1_WEB_WANT_SPACE 3 + +static int cn1WebStreamSatisfied(CN1WebStream* st, int want) { + if(st->done) { + return 1; + } + switch(want) { + case CN1_WEB_WANT_HEADERS: + return st->headersReady; + case CN1_WEB_WANT_DATA: + return st->inLength > st->inOffset; + default: + return st->outOffset >= st->outLength; + } +} + +/* Drives the transfer until `want` holds or it ends, resuming whichever side + now has room or data first. */ +static void cn1WebStreamStep(CN1WebStream* st, int want, int park) { + CURLMsg* msg; + int left; + while(!cn1WebStreamSatisfied(st, want)) { + int wanted = st->paused; + if((wanted & CURLPAUSE_RECV) && st->inLength - st->inOffset < CN1_WEB_STREAM_WINDOW) { + wanted &= ~CURLPAUSE_RECV; + } + if((wanted & CURLPAUSE_SEND) && (st->outOffset < st->outLength || st->outFinished)) { + wanted &= ~CURLPAUSE_SEND; + } + if(wanted != st->paused) { + /* Callbacks run inside this call may pause again; they OR their bit + back in, which is why the state is assigned first. */ + st->paused = wanted; + curl_easy_pause(st->curl, wanted); + continue; + } + if(st->running) { + cn1WebTurn(st->multi, &st->watch, park, &st->running); + } + while((msg = curl_multi_info_read(st->multi, &left)) != NULL) { + if(msg->msg == CURLMSG_DONE) { + st->done = 1; + st->rc = msg->data.result; + } + } + if(!st->running && !st->done) { + st->done = 1; + st->rc = CURLE_OK; + } + } +} + +/* Copies what the transfer now knows into the response Java reads. */ +static void cn1WebStreamReport(CN1WebStream* st) { + CN1WebResponse* r = st->response; + if(st->done && st->rc != CURLE_OK) { + r->status = -1; + if(r->error[0] == 0) { + const char* msg = curl_easy_strerror(st->rc); + strncpy(r->error, msg == NULL ? "transfer failed" : msg, CURL_ERROR_SIZE - 1); + } + if(st->h2check.refused || st->h2check.unverifiable) { + strncpy(r->error, "HTTP/2 is required, and the server did not negotiate it; " + "the request was not sent", CURL_ERROR_SIZE - 1); + } + return; + } + if(st->headersReady || st->done) { + curl_easy_getinfo(st->curl, CURLINFO_RESPONSE_CODE, &r->status); +#if LIBCURL_VERSION_NUM >= 0x073200 + curl_easy_getinfo(st->curl, CURLINFO_HTTP_VERSION, &r->httpVersion); +#endif + if((st->flags & CN1_WEB_FLAG_REQUIRE_HTTP2) && r->httpVersion != CURL_HTTP_VERSION_2_0 + && r->httpVersion != 0) { + r->status = -1; + strncpy(r->error, "HTTP/2 is required, and the server answered over another " + "protocol", CURL_ERROR_SIZE - 1); + } + } +} + +static void cn1WebStreamFree(CN1WebStream* st) { + if(st == NULL) { + return; + } + /* Removing a transfer that has not finished aborts it and closes its + connection: this is what cancelling an exchange is. */ + curl_multi_remove_handle(st->multi, st->curl); + curl_easy_cleanup(st->curl); + curl_multi_cleanup(st->multi); + curl_slist_free_all(st->headers); + cn1WebFreeSecret(st->certCopy, st->certLength); + cn1WebFreeSecret(st->keyCopy, st->keyLength); + cn1WebFreeSecret(st->passwordCopy, st->passwordLength); + free(st->urlCopy); + free(st->methodCopy); + free(st->bodyCopy); + free(st->in); + free(st->out); + free(st); +} +#endif + #define CN1_WEB_FLAG_CLIENT 2 #define CN1_WEB_FLAG_NO_REUSE 4 /* ClientCertificate's types. */ #define CN1_WEB_CERT_PEM 0 #define CN1_WEB_CERT_PKCS12 1 -JAVA_LONG com_codename1_backend_Web_performImpl___long_java_lang_String_java_lang_String_byte_1ARRAY_byte_1ARRAY_boolean_int_int_int_int_byte_1ARRAY_byte_1ARRAY_int_java_lang_String_R_long( +JAVA_LONG com_codename1_backend_Web_performImpl___long_java_lang_String_java_lang_String_byte_1ARRAY_byte_1ARRAY_boolean_int_int_int_int_byte_1ARRAY_byte_1ARRAY_int_java_lang_String_long_R_long( CODENAME_ONE_THREAD_STATE, JAVA_LONG poolHandle, JAVA_OBJECT method, JAVA_OBJECT url, JAVA_OBJECT headerLines, JAVA_OBJECT body, JAVA_BOOLEAN callerHeaders, JAVA_INT flags, JAVA_INT connectTimeoutMillis, JAVA_INT readTimeoutMillis, JAVA_INT requestTimeoutMillis, - JAVA_OBJECT certificate, JAVA_OBJECT key, JAVA_INT certificateType, JAVA_OBJECT password) { + JAVA_OBJECT certificate, JAVA_OBJECT key, JAVA_INT certificateType, JAVA_OBJECT password, + JAVA_LONG uploadLength) { CURL* curl; CURLcode rc; struct curl_slist* headers = NULL; @@ -1164,6 +1395,76 @@ JAVA_LONG com_codename1_backend_Web_performImpl___long_java_lang_String_java_lan /* The transfer blocks; yield so the concurrent collector is not stalled by it. On a virtual thread it PARKS instead of blocking, so its host goes on running every other virtual thread; see cn1WebPerformParked. */ + if(flags & CN1_WEB_FLAG_STREAM) { + /* Set up like any other transfer, stepped from Java from here on; see + CN1WebStream. uploadLength is -2 for no request body, -1 for one of + unknown length (chunked on HTTP/1.1), or its byte count. */ +#ifndef _WIN32 + CN1WebStream* st = (CN1WebStream*)calloc(1, sizeof(CN1WebStream)); + CURLM* multi = st == NULL ? NULL : curl_multi_init(); + if(multi != NULL) { + st->multi = multi; + cn1WebWatchMulti(multi, &st->watch); + st->curl = curl; + st->headers = headers; + st->methodCopy = methodCopy; + st->urlCopy = urlCopy; + st->bodyCopy = bodyCopy; + st->certCopy = certCopy; + st->certLength = certLength; + st->keyCopy = keyCopy; + st->keyLength = keyLength; + st->passwordCopy = passwordCopy; + st->passwordLength = passwordLength; + st->h2check = h2check; + st->h2check.curl = curl; + st->flags = flags; + st->response = r; + r->stream = st; +#if LIBCURL_VERSION_NUM >= 0x075000 + if(flags & CN1_WEB_FLAG_REQUIRE_HTTP2) { + /* The stack copy performImpl registered dies when it returns. */ + curl_easy_setopt(curl, CURLOPT_PREREQDATA, &st->h2check); + } +#endif + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, cn1WebStreamWrite); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, st); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, cn1WebStreamHeader); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, st); + if(uploadLength >= -1) { + curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L); + curl_easy_setopt(curl, CURLOPT_READFUNCTION, cn1WebStreamRead); + curl_easy_setopt(curl, CURLOPT_READDATA, st); + curl_easy_setopt(curl, CURLOPT_INFILESIZE_LARGE, (curl_off_t)uploadLength); + } + if(curl_multi_add_handle(multi, curl) == CURLM_OK) { + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &st->running); + return (JAVA_LONG)(intptr_t)r; + } + /* Never added, so the remove in cn1WebStreamFree is a no-op. */ + r->stream = NULL; + strncpy(r->error, "could not start the streamed transfer", CURL_ERROR_SIZE - 1); + r->status = -1; + cn1WebStreamFree(st); + return (JAVA_LONG)(intptr_t)r; + } + free(st); + strncpy(r->error, "could not start the streamed transfer", CURL_ERROR_SIZE - 1); +#else + strncpy(r->error, "streamed exchanges are not supported on Windows yet", + CURL_ERROR_SIZE - 1); +#endif + r->status = -1; + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + cn1WebFreeSecret(certCopy, certLength); + cn1WebFreeSecret(keyCopy, keyLength); + cn1WebFreeSecret(passwordCopy, passwordLength); + free(urlCopy); free(methodCopy); free(bodyCopy); + return (JAVA_LONG)(intptr_t)r; + } + CN1_YIELD_THREAD; #ifndef _WIN32 { @@ -1288,8 +1589,109 @@ JAVA_OBJECT com_codename1_backend_Web_headersImpl___long_R_java_lang_String(CODE JAVA_VOID com_codename1_backend_Web_freeImpl___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { CN1WebResponse* r = (CN1WebResponse*)(intptr_t)handle; if(r != NULL) { +#ifndef _WIN32 + cn1WebStreamFree((CN1WebStream*)r->stream); +#endif free(r->data); free(r->headers); free(r); } } + +/* + * Web.Exchange's natives. Each takes the handle performImpl returned with + * CN1_WEB_FLAG_STREAM; on one that has no stream (setup failed, or Windows) + * they report the transfer as ended, and statusImpl/errorImpl say why. + */ + +/* Drives until `want` (CN1_WEB_WANT_*) holds; answers bit 0 headers ready, + bit 1 ended. */ +JAVA_INT com_codename1_backend_Web_streamStepImpl___long_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle, JAVA_INT want) { + CN1WebResponse* r = (CN1WebResponse*)(intptr_t)handle; +#ifndef _WIN32 + CN1WebStream* st = r == NULL ? NULL : (CN1WebStream*)r->stream; + if(st != NULL) { + CN1_YIELD_THREAD; + cn1WebStreamStep(st, (int)want, cn1BackendCanPark()); + CN1_RESUME_THREAD; + cn1WebStreamReport(st); + return (st->headersReady ? 1 : 0) | (st->done ? 2 : 0); + } +#else + (void)want; +#endif + (void)r; + return 3; +} + +/* Takes up to `length` buffered response bytes; 0 when none are buffered. */ +JAVA_INT com_codename1_backend_Web_streamReadImpl___long_byte_1ARRAY_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length) { + CN1WebResponse* r = (CN1WebResponse*)(intptr_t)handle; +#ifndef _WIN32 + CN1WebStream* st = r == NULL ? NULL : (CN1WebStream*)r->stream; + size_t n; + if(st == NULL || buffer == JAVA_NULL || length <= 0) { + return 0; + } + n = st->inLength - st->inOffset; + if(n > (size_t)length) { + n = (size_t)length; + } + memcpy((JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer) + offset, st->in + st->inOffset, n); + st->inOffset += n; + if(st->inOffset == st->inLength) { + st->inOffset = 0; + st->inLength = 0; + } + return (JAVA_INT)n; +#else + (void)r; (void)buffer; (void)offset; (void)length; + return 0; +#endif +} + +/* Hands the transfer the next block of the request body, which must be + called only once the previous one was taken (CN1_WEB_WANT_SPACE); the + count accepted. */ +JAVA_INT com_codename1_backend_Web_streamWriteImpl___long_byte_1ARRAY_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length) { + CN1WebResponse* r = (CN1WebResponse*)(intptr_t)handle; +#ifndef _WIN32 + CN1WebStream* st = r == NULL ? NULL : (CN1WebStream*)r->stream; + size_t n = (size_t)length; + if(st == NULL || buffer == JAVA_NULL || length <= 0 || st->outOffset < st->outLength + || st->outFinished) { + return 0; + } + if(n > CN1_WEB_STREAM_WINDOW) { + n = CN1_WEB_STREAM_WINDOW; + } + if(st->out == NULL) { + st->out = (unsigned char*)malloc(CN1_WEB_STREAM_WINDOW); + if(st->out == NULL) { + /* -1, never 0: nothing will ever be pending, so the step that + answers 0 with would return at once and the writer loop spin. */ + return -1; + } + } + memcpy(st->out, (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer) + offset, n); + st->outOffset = 0; + st->outLength = n; + return (JAVA_INT)n; +#else + (void)r; (void)buffer; (void)offset; (void)length; + return 0; +#endif +} + +/* The request body is complete. */ +JAVA_VOID com_codename1_backend_Web_streamEndUploadImpl___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + CN1WebResponse* r = (CN1WebResponse*)(intptr_t)handle; +#ifndef _WIN32 + CN1WebStream* st = r == NULL ? NULL : (CN1WebStream*)r->stream; + if(st != NULL) { + st->outFinished = 1; + } +#else + (void)r; +#endif +} diff --git a/vm/backend/src/com/codename1/backend/HttpProxy.java b/vm/backend/src/com/codename1/backend/HttpProxy.java new file mode 100644 index 00000000000..2e1c9fa40c9 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/HttpProxy.java @@ -0,0 +1,493 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Forwards a request to another HTTP server and streams its answer back: a +/// reverse proxy, an API gateway route, a pass-through to an internal service. +/// +/// ```java +/// HttpProxy images = new HttpProxy("http://images.internal:8080"); +/// // in a handler: +/// return images.forward(request); // same path and query upstream +/// return images.forward(request, "/v2/thumbs"); // or a path of your choosing +/// ``` +/// +/// What it guarantees: +/// +/// - **Bounded memory.** A binary request body is streamed up from wherever it +/// is -- a spooled upload included -- and the response body is streamed down +/// as it arrives ([Web.Exchange]). A text or compressed request body is the +/// server's in-memory one (under its 8 MB limit), forwarded decoded. +/// - **Headers survive, hop-by-hop ones do not.** Every end-to-end header goes +/// through in both directions, a repeated one (`Set-Cookie`) line by line. +/// The hop-by-hop set of RFC 9110 7.6.1 -- `Connection`, `Keep-Alive`, +/// `Proxy-*`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade` -- and every +/// header a `Connection` field names are removed each way. `Host` and +/// `Content-Length` are the transport's. `X-Forwarded-For`, `-Proto` and +/// `-Host` are added unless [#setForwardedHeaders] turns them off. +/// - **Destinations are fixed.** Only the target's own host is reached, plus +/// hosts named with [#allowHost]; a redirect elsewhere is passed back to the +/// client, never followed. Only `http` and `https`. +/// - **Redirects are passed through** by default. [#setFollowRedirects] follows +/// up to that many hops itself, for a request with no body only -- a streamed +/// body cannot be sent twice -- and only to allowed hosts. A hop to another +/// host drops the client's `Authorization` and `Cookie`. +/// - **Cancellation goes both ways.** A client that disconnects mid-download +/// makes the next write fail, which closes the upstream exchange at once. An +/// upstream that fails before answering is a `502`; one that fails after the +/// status and headers went out cannot be turned into an error response any +/// more, so the client's response is aborted -- the HTTP/1.1 connection is +/// closed without its final chunk, the HTTP/2 stream is reset -- and the +/// client sees a truncated response, never a complete-looking one. +/// +/// The response is re-framed by this server: chunked on HTTP/1.1, DATA frames on +/// HTTP/2, whatever length the upstream declared. One instance serves any number +/// of concurrent requests once configured. +public final class HttpProxy { + private final String base; + private final String baseHost; + private final List allowedHosts = new ArrayList(); + private WebClient client; + private int followRedirects; + private boolean forwardedHeaders = true; + + /// - `target`: the upstream's origin and optional base path, such as + /// `https://api.internal:8443` or `http://10.0.0.5/service` + public HttpProxy(String target) throws IOException { + Urls.requireHttp(target); + String trimmed = target; + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + this.base = trimmed; + this.baseHost = hostOf(trimmed); + if (baseHost == null) { + throw new IllegalArgumentException("the proxy target names no host"); + } + } + + /// The client upstream requests go through, for its timeouts and client + /// certificate; a default one when unset. + public HttpProxy setClient(WebClient client) { + this.client = client; + return this; + } + + /// Lets requests -- redirects and [#forward(HttpServer.Request, String)] + /// with an absolute URL -- reach `host` (a name, or `name:port`) as well as + /// the target's own. + public HttpProxy allowHost(String host) { + if (host != null && host.length() > 0) { + allowedHosts.add(asciiLower(host)); + } + return this; + } + + /// How many redirects to follow on the client's behalf; 0, the default, + /// hands every 3xx back to the client as it came. + public HttpProxy setFollowRedirects(int hops) { + this.followRedirects = Math.max(0, hops); + return this; + } + + /// Whether to add `X-Forwarded-For`, `X-Forwarded-Proto` and + /// `X-Forwarded-Host`; on by default. + public HttpProxy setForwardedHeaders(boolean forwardedHeaders) { + this.forwardedHeaders = forwardedHeaders; + return this; + } + + /// Forwards to the target with the request's own path and query appended. + public HttpServer.Response forward(HttpServer.Request request) { + return forward(request, request.getTarget()); + } + + /// Forwards to `pathOrUrl`: a path (with any query) appended to the target, + /// or an absolute URL, which must name an allowed host. + public HttpServer.Response forward(HttpServer.Request request, String pathOrUrl) { + String url = pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://") + ? pathOrUrl + : base + (pathOrUrl.startsWith("/") ? "" : "/") + pathOrUrl; + if (!isAllowed(url)) { + return HttpServer.Response.text(502, "the proxy may not reach that destination"); + } + String method = request.getMethod(); + List headers = requestHeaders(request); + long length = request.getBodyLength(); + boolean hasBody = length > 0 || request.getHeader("Content-Length") != null + || request.getHeader("Transfer-Encoding") != null; + Web.Exchange exchange = null; + boolean handedOver = false; + try { + int hops = 0; + int status; + while (true) { + exchange = open(method, url, headers, hasBody ? length : Web.NO_BODY); + if (hasBody && length > 0) { + InputStream in = request.getBodyStream(); + try { + OutputStream out = exchange.getRequestBody(); //NOPMD CloseResource - closed below; the exchange owns it + copy(in, out); + out.close(); + } finally { + in.close(); + } + } + status = exchange.getStatus(); + String location = exchange.getHeader("Location"); + // A body of no bytes can be sent again; only a real one cannot. + String next = hops < followRedirects && length == 0 && isRedirect(status) + && location != null ? resolve(url, location) : null; + if (next == null || !isAllowed(next)) { + break; + } + exchange.close(); + exchange = null; + String nextHost = hostOf(next); + if (nextHost == null || !nextHost.equalsIgnoreCase(hostOf(url)) + || !url.regionMatches(true, 0, next, 0, next.indexOf(':') + 1)) { + // The client's credentials were meant for the host it asked; + // another one, allowed or not, does not get them, nor does the + // same host over another scheme (https to http would send them + // in the clear) -- as curl + // and browsers drop them on a redirect across hosts. + headers = withoutCredentials(headers); + } + url = next; + hops++; + if (status == 303) { + method = "HEAD".equals(method) ? "HEAD" : "GET"; + } + } + HttpServer.Response response = respond(request, exchange, status); + handedOver = true; + return response; + } catch (IOException err) { + // Before any of the response went out, so it can still be answered. + return HttpServer.Response.text(502, "the upstream request failed"); + } finally { + if (!handedOver && exchange != null) { + exchange.close(); + } + } + } + + private Web.Exchange open(String method, String url, List headers, long length) + throws IOException { + return client != null ? client.open(method, url, headers, length) + : Web.open(method, url, headers, length); + } + + /// The client's response: the upstream status and end-to-end headers, and a + /// body that streams from the exchange, which the writer then owns. + private static HttpServer.Response respond(HttpServer.Request request, + final Web.Exchange exchange, int status) + throws IOException { + Map values = exchange.getHeaderValueMap(); + List dropped = connectionTokens(values.get("connection")); + Map out = new LinkedHashMap(); + String contentType = null; + Iterator it = values.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + String name = (String) e.getKey(); + if ("content-type".equals(name)) { + List all = (List) e.getValue(); + contentType = all.isEmpty() ? null : (String) all.get(0); + continue; + } + if (isHopByHop(name) || dropped.contains(name) || "content-length".equals(name) + || "date".equals(name)) { + continue; + } + List all = (List) e.getValue(); + out.put(name, all.size() == 1 ? all.get(0) : new ArrayList(all)); + } + boolean bodiless = "HEAD".equals(request.getMethod()) || status == 204 || status == 304 + || (status >= 100 && status < 200); + long described = digits(values.get("content-length")); + if (bodiless && (described >= 0 || !"HEAD".equals(request.getMethod()))) { + exchange.close(); + HttpServer.Response head = HttpServer.Response.empty(status, contentType, out); + // A HEAD describes the representation it does not send: the + // upstream's length, not the 0 of this empty body. + head.describedLength = "HEAD".equals(request.getMethod()) ? described : -1; + return head; + } + // From here a HEAD is one whose upstream gave no length, and it goes out + // as a stream so that none is invented: the server never runs a HEAD's + // writer, and closes it -- ending the exchange -- instead. + HttpServer.Response response = HttpServer.Response.stream(status, contentType, + new Relay(exchange)); + response.extraHeaders = out; + return response; + } + + /// Copies the upstream body to the client. Closeable because the server + /// does not always run a writer (a client gone before the headers, an + /// HTTP/2 stream it could not open), and the exchange must end either way. + private static final class Relay implements HttpServer.BodyWriter, Closeable { + private final Web.Exchange exchange; + + Relay(Web.Exchange exchange) { + this.exchange = exchange; + } + + @Override + public void writeTo(OutputStream output) throws IOException { + try { + copy(exchange.getResponseBody(), output); + } finally { + // Complete, or cancelled: either way the upstream is done, and on + // a client that went away this is what stops it sending. + exchange.close(); + } + } + + @Override + public void close() { + exchange.close(); + } + } + + /// A Content-Length value as a number, or -1 for none or anything but + /// plain digits (a list of several is not one length). + private static long digits(Object value) { + Object one = value; + if (one instanceof List) { + List all = (List) one; + one = all.size() == 1 ? all.get(0) : null; + } + if (!(one instanceof String)) { + return -1; + } + String text = ((String) one).trim(); + if (text.length() == 0 || text.length() > 18) { + return -1; + } + long n = 0; + for (int iter = 0; iter < text.length(); iter++) { + char c = text.charAt(iter); + if (c < '0' || c > '9') { + return -1; + } + n = n * 10 + (c - '0'); + } + return n; + } + + private static List withoutCredentials(List headers) { + List out = new ArrayList(); + for (Object each : headers) { + String line = (String) each; + if (!line.regionMatches(true, 0, "authorization:", 0, 14) + && !line.regionMatches(true, 0, "cookie:", 0, 7)) { + out.add(line); + } + } + return out; + } + + private List requestHeaders(HttpServer.Request request) { + Map headers = request.getHeaders(); + List dropped = connectionTokens(headers.get("connection")); + List out = new ArrayList(); + String forwardedFor = null; + Iterator it = headers.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + String name = (String) e.getKey(); + String value = String.valueOf(e.getValue()); + // Content-Encoding goes too: what is forwarded is always identity. A + // spooled body is identity by definition, and one the client sent + // gzip encoded was decoded on arrival (getBodyBytes), so passing the + // header on would label plain bytes as gzip. + if (isHopByHop(name) || dropped.contains(name) || "host".equals(name) + || "content-length".equals(name) || "expect".equals(name) + || "content-encoding".equals(name)) { + continue; + } + if (forwardedHeaders && "x-forwarded-for".equals(name)) { + forwardedFor = value; + continue; + } + if (forwardedHeaders && ("x-forwarded-proto".equals(name) + || "x-forwarded-host".equals(name))) { + continue; + } + out.add(name + ": " + value); + } + if (forwardedHeaders) { + String peer = request.getPeerAddress(); + if (peer != null) { + out.add("x-forwarded-for: " + (forwardedFor == null ? peer + : forwardedFor + ", " + peer)); + } else if (forwardedFor != null) { + out.add("x-forwarded-for: " + forwardedFor); + } + out.add("x-forwarded-proto: " + (request.isSecure() ? "https" : "http")); + String host = request.getHeader("Host"); + if (host != null) { + out.add("x-forwarded-host: " + host); + } + } + return out; + } + + /// RFC 9110 7.6.1, plus the Proxy-Connection some clients still send. + static boolean isHopByHop(String lowerName) { + return "connection".equals(lowerName) || "keep-alive".equals(lowerName) + || "proxy-connection".equals(lowerName) + || "proxy-authenticate".equals(lowerName) + || "proxy-authorization".equals(lowerName) || "te".equals(lowerName) + || "trailer".equals(lowerName) || "transfer-encoding".equals(lowerName) + || "upgrade".equals(lowerName); + } + + /// The header names a Connection field lists, lower-cased; a value may be a + /// String or, from a response, a List of lines. + static List connectionTokens(Object value) { + List out = new ArrayList(); + if (value == null) { + return out; + } + List lines = new ArrayList(); + if (value instanceof List) { + lines.addAll((List) value); + } else { + lines.add(value); + } + for (Object line : lines) { + String text = String.valueOf(line); + int at = 0; + while (at <= text.length()) { + int comma = text.indexOf(',', at); + int end = comma < 0 ? text.length() : comma; + String token = text.substring(at, end).trim(); + if (token.length() > 0) { + out.add(asciiLower(token)); + } + if (comma < 0) { + break; + } + at = comma + 1; + } + } + return out; + } + + private boolean isAllowed(String url) { + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return false; + } + String host = hostOf(url); + if (host == null) { + return false; + } + if (host.equals(baseHost) || allowedHosts.contains(host)) { + return true; + } + int colon = host.lastIndexOf(':'); + return colon > 0 && host.indexOf(']') < colon + && allowedHosts.contains(host.substring(0, colon)); + } + + /// `host[:port]` of an absolute http(s) URL, lower-cased, or null. Userinfo + /// is refused rather than parsed, since `a@b` is how a destination check is + /// usually walked around. + static String hostOf(String url) { + int start = url.indexOf("://"); + if (start < 0) { + return null; + } + start += 3; + int end = start; + while (end < url.length() && "/?#".indexOf(url.charAt(end)) < 0) { + end++; + } + String authority = url.substring(start, end); + if (authority.length() == 0 || authority.indexOf('@') >= 0 + || authority.indexOf('\\') >= 0) { + return null; + } + return asciiLower(authority); + } + + private static boolean isRedirect(int status) { + return status == 301 || status == 302 || status == 303 || status == 307 + || status == 308; + } + + /// A Location against the URL that produced it: absolute, or rooted at its + /// origin. Relative-path references are uncommon enough in a Location to be + /// handed back to the client instead. + static String resolve(String current, String location) { + if (location.startsWith("http://") || location.startsWith("https://")) { + return location; + } + if (location.startsWith("//")) { + int colon = current.indexOf(':'); + return current.substring(0, colon + 1) + location; + } + if (location.startsWith("/")) { + int start = current.indexOf("://") + 3; + int end = start; + while (end < current.length() && "/?#".indexOf(current.charAt(end)) < 0) { + end++; + } + return current.substring(0, end) + location; + } + return null; + } + + private static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[16 * 1024]; + int n; + while ((n = in.read(buffer, 0, buffer.length)) >= 0) { + if (n > 0) { + out.write(buffer, 0, n); + } + } + } + + private static String asciiLower(String value) { + char[] chars = value.toCharArray(); + for (int iter = 0 ; iter < chars.length ; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } +} diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 8ff2c67bb0f..cdca56654ae 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -23,6 +23,7 @@ package com.codename1.backend; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -89,6 +90,9 @@ public static final class Request { private byte[] encodedBody; /// The parts of a multipart body, split on first ask; see [#getParts]. private List parts; + /// A binary body too large to keep in memory, on disk until [#release]; + /// null when the body is in [#body] or [#bodyBytes]. See [#getBodyStream]. + private Spool bodySpool; /// The connection this request arrived on; null for HTTP/2, see respond. private Conn conn; /// The bytes the header block was read from. @@ -571,6 +575,7 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, /// an upload is the whole of it. Every field cleared here is assigned again /// by reset() before anything reads it. void releaseRetained() { + release(); this.raw = null; this.slices = null; this.body = null; @@ -755,12 +760,36 @@ void setBody(String value) { /// The body as [#bodyContent] decided it: text, or bytes, never both. void setBody(String text, byte[] binary) { + release(); this.body = text; this.bodyBytes = binary; this.encodedBody = null; this.parts = null; } + /// A binary body that was spooled while it arrived. The request owns it + /// from here and closes it in [#release]. + void setBody(Spool spooled) { + release(); + this.body = null; + this.bodyBytes = null; + this.encodedBody = null; + this.parts = null; + this.bodySpool = spooled; + } + + /// Ends this request's hold on a spooled body, deleting its file. The + /// server calls it when the exchange is over -- answered, failed, or the + /// peer gone -- so streams handed out by [#getBodyStream] and + /// [Part#getInputStream] stop working then. + void release() { + if (bodySpool != null) { + bodySpool.close(); + bodySpool = null; + parts = null; + } + } + void reset(Conn conn, String method, String target, String version, byte[] raw, int[] slices, int headerCount, String body, int targetStart, int targetLength) { @@ -771,6 +800,9 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.raw = raw; this.slices = slices; this.headerCount = headerCount; + // A spool the previous request on this connection left is closed + // here at the latest; the serving loop's finally normally has. + release(); this.body = body; this.bodyBytes = null; this.encodedBody = null; @@ -1062,6 +1094,9 @@ int countHeader(String name) { /// when they are not, rather than handing a handler characters the client /// never sent. public String getBody() { + if (body == null && bodySpool != null) { + getBodyBytes(); + } if (body == null && bodyBytes != null) { if (!Utf8.isValid(bodyBytes, 0, bodyBytes.length)) { throw new IllegalStateException("The request body is binary (" @@ -1080,10 +1115,27 @@ public String getBody() { /// it arrived -- decompressed when the client sent it gzip encoded -- and a /// text body as its UTF-8. The array is the request's; copy it to keep it /// beyond the handler. + /// + /// @throws IllegalStateException for a body larger than the in-memory + /// limit (`CN1_HTTP_MAX_BODY_MB`), which only a spooled binary body can + /// be; read that one with [#getBodyStream] public byte[] getBodyBytes() { if (bodyBytes != null) { return bodyBytes; } + if (bodySpool != null) { + if (bodySpool.length() > MAX_BODY_BYTES) { + throw new IllegalStateException("The request body is " + bodySpool.length() + + " bytes, past the in-memory limit; read it with getBodyStream()"); + } + try { + bodyBytes = bodySpool.toBytes(); + } catch (IOException err) { + throw new IllegalStateException("could not read the spooled body: " + + err.getMessage(), err); + } + return bodyBytes; + } if (body == null) { return null; } @@ -1093,6 +1145,30 @@ public byte[] getBodyBytes() { return encodedBody; } + /// The body as a stream, whatever its size: the way to read an upload that + /// may be larger than the server keeps in memory. A binary body past + /// `CN1_HTTP_SPOOL_THRESHOLD_KB` was written to an unlinked temporary file + /// as it arrived, and this reads that file; a smaller one is read from + /// memory. Each call starts from the beginning. Valid for the duration of + /// the request -- the file is deleted once the exchange ends. + public InputStream getBodyStream() { + if (bodySpool != null) { + return bodySpool.open(); + } + byte[] bytes = getBodyBytes(); + return new java.io.ByteArrayInputStream(bytes == null ? EMPTY_BODY : bytes); + } + + /// The body's length in bytes, 0 when there is none, without reading a + /// spooled one into memory. + public long getBodyLength() { + if (bodySpool != null) { + return bodySpool.length(); + } + byte[] bytes = getBodyBytes(); + return bytes == null ? 0 : bytes.length; + } + /// The parts of a `multipart/form-data` body, in order; an empty list /// when the body is not one. Throws IllegalArgumentException for a body /// that says it is multipart and is not well formed, which a generated @@ -1108,6 +1184,11 @@ public List getParts() { throw new IllegalArgumentException("the multipart/form-data body names no " + "valid boundary"); } + if (boundary != null && bodySpool != null) { + // Parsed in place: each part is a slice of the spool file. + parts = Multipart.parse(bodySpool, boundary); + return parts; + } byte[] data = boundary == null ? null : getBodyBytes(); if (boundary != null && (data == null || data.length == 0)) { // Even a form with no fields carries its closing delimiter, so @@ -1165,7 +1246,13 @@ public static final class Part { private final String filename; private final String contentType; private final Map headers; - private final byte[] data; + /// The content in memory; null for a part of a spooled body until + /// [#getBytes] reads it. + private byte[] data; + /// The spooled body this part is a slice of, or null. + private final Spool source; + private final long offset; + private final long length; Part(String name, String filename, String contentType, Map headers, byte[] data) { this.name = name; @@ -1173,6 +1260,21 @@ public static final class Part { this.contentType = contentType; this.headers = headers; this.data = data; + this.source = null; + this.offset = 0; + this.length = data.length; + } + + /// A part of a body spooled to disk, read in place rather than copied. + Part(String name, String filename, String contentType, Map headers, Spool source, + long offset, long length) { + this.name = name; + this.filename = filename; + this.contentType = contentType; + this.headers = headers; + this.source = source; + this.offset = offset; + this.length = length; } /// The field name from its Content-Disposition, or null. @@ -1206,13 +1308,46 @@ public String getHeader(String headerName) { return null; } - /// The part's content length in bytes. + /// The part's content length in bytes, capped at [Integer#MAX_VALUE]; see + /// [#getLength] for a part that may be larger. public int getSize() { - return data.length; + return length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length; + } + + /// The part's content length in bytes. + public long getLength() { + return length; + } + + /// The part's content as a stream, which is how to read a large upload: a + /// part of a body that was spooled to disk is read from the spool file, + /// never brought into memory whole. The stream is valid for the duration + /// of the request; the file is gone once the response has been sent. + public InputStream getInputStream() { + if (data != null) { + return new java.io.ByteArrayInputStream(data); + } + return source.open(offset, length); } /// The part's content. The array is the part's; copy it to change it. + /// + /// @throws IllegalStateException for a part larger than the server's + /// in-memory body limit (`CN1_HTTP_MAX_BODY_MB`), which only a spooled + /// body can hold; read that one with [#getInputStream] public byte[] getBytes() { + if (data == null) { + if (length > MAX_BODY_BYTES) { + throw new IllegalStateException("Part \"" + name + "\" is " + length + + " bytes, past the in-memory limit; read it with getInputStream()"); + } + try { + data = source.toBytes(offset, (int) length); + } catch (IOException err) { + throw new IllegalStateException("could not read part \"" + name + "\": " + + err.getMessage(), err); + } + } return data; } @@ -1220,11 +1355,12 @@ public byte[] getBytes() { /// is not valid UTF-8 -- a binary upload -- rather than returning /// replacement characters the client never sent. public String getText() { - if (!Utf8.isValid(data, 0, data.length)) { + byte[] bytes = getBytes(); + if (!Utf8.isValid(bytes, 0, bytes.length)) { throw new IllegalStateException("Part \"" + name + "\" is not UTF-8 text"); } try { - return new String(data, "UTF-8"); + return new String(bytes, "UTF-8"); } catch (java.io.UnsupportedEncodingException err) { throw new IllegalStateException(err.toString(), err); } @@ -1238,6 +1374,13 @@ public String getText() { /// A disconnect, reset or socket timeout makes writes throw IOException; let it /// propagate so generation stops and source cleanup runs. No temporary files or /// producer threads are created. Closing output flushes it, but not the connection. + /// + /// A writer that holds something open until it runs -- an upstream + /// connection, a file -- should also implement `java.io.Closeable`. The + /// server does not always call [#writeTo(OutputStream)]: a HEAD request, a + /// status that carries no body, an HTTP/2 stream it cannot open, or a client + /// gone before the headers were written all skip it. In each of those it + /// calls `close()` instead, so the writer's resource is released either way. public interface BodyWriter { void writeTo(OutputStream output) throws IOException; } @@ -1261,6 +1404,11 @@ public static final class Response { Object deferredJson; boolean hasDeferredJson; BodyWriter bodyWriter; + /// The length a HEAD answer advertises for a bodiless response, or -1 to + /// measure the body as usual: HttpProxy's HEAD carries the upstream's + /// figure for a representation it never fetched. Content-Length belongs + /// to the server (isServerOwnedHeader), so this is how it is passed. + long describedLength = -1; /// Drops what this response pointed at, once it has been written. /// @@ -1272,6 +1420,7 @@ void releaseRetained() { this.deferredJson = null; this.hasDeferredJson = false; this.bodyWriter = null; + this.describedLength = -1; this.body = EMPTY_BODY; this.extraHeaders = null; } @@ -1284,6 +1433,21 @@ void discard() { StaticFiles.closeFile(fileFd); fileFd = -1; } + releaseWriter(); + } + + /// Closes a Closeable writer that will never run, and forgets it. + void releaseWriter() { + BodyWriter writer = bodyWriter; + bodyWriter = null; + if (writer instanceof java.io.Closeable) { + try { + ((java.io.Closeable) writer).close(); + } catch (IOException ignored) { + // Releasing what a response that is never sent held; there is + // nobody left to report a failure to. + } + } } /// Serialises a deferred JSON body now, into an ordinary one, so the @@ -1312,6 +1476,7 @@ void reset(int status, String contentType, byte[] body, int fileFd, this.deferredJson = null; this.hasDeferredJson = false; this.bodyWriter = null; + this.describedLength = -1; } public Response(int status, String contentType, byte[] body) { @@ -1353,6 +1518,7 @@ Response withHeaders(Map headers) { copy.deferredJson = deferredJson; copy.hasDeferredJson = hasDeferredJson; copy.bodyWriter = bodyWriter; + copy.describedLength = describedLength; return copy; } @@ -1759,6 +1925,38 @@ void releaseWebSocketMemory(long amount) { private static final long MAX_OPEN_H2_BODY_BYTES = envInt("CN1_HTTP_MAX_H2_BODY_MB", 64) * 1024L * 1024L; private static final int MAX_BODY_BYTES = 8 * 1024 * 1024; + + /// The largest BINARY, identity-encoded request body taken -- an upload, a + /// `multipart/form-data` form -- on either protocol. Such a body past + /// [#SPOOL_THRESHOLD] is written to an unlinked temporary file as it + /// arrives and read through Request.getBodyStream, so this bounds disk, not + /// heap. Text bodies (JSON, forms, XML) and compressed ones stay in memory + /// and under MAX_BODY_BYTES, because a handler reads them whole anyway. + /// The default is MAX_BODY_BYTES, so nothing changes until it is raised. + /// Capped below 2 GiB: an HTTP/1 Content-Length is read as an int. + static final long MAX_SPOOLED_BODY_BYTES = Math.min(Integer.MAX_VALUE, + Math.max(MAX_BODY_BYTES, envInt("CN1_HTTP_MAX_SPOOLED_BODY_MB", 8) * 1024L * 1024L)); + + /// The heap a spoolable body may take before it goes to disk. + static final int SPOOL_THRESHOLD = Math.max(16 * 1024, + Math.min(MAX_BODY_BYTES, envInt("CN1_HTTP_SPOOL_THRESHOLD_KB", 1024) * 1024)); + + /// Where spool files go; null for the system temporary directory. Each is + /// unlinked as it is created, so nothing is ever left there. + static final String SPOOL_DIRECTORY = emptyToNull(System.getenv("CN1_HTTP_SPOOL_DIR")); + + private static String emptyToNull(String value) { + return value == null || value.trim().length() == 0 ? null : value.trim(); + } + + /// Whether a body with these headers may be spooled: binary by the rule + /// bodyContent applies, and not content-encoded, since an encoded body has + /// to be decoded -- in memory -- before anyone can read it. + static boolean isSpoolable(String contentType, String contentEncoding) { + return isBinaryType(contentType) + && (contentEncoding == null || contentEncoding.trim().length() == 0 + || "identity".equalsIgnoreCase(contentEncoding.trim())); + } private static final int READY_CAPACITY = 256; /// Bodies at or below this are sent together with the headers in one write. @@ -5773,6 +5971,54 @@ boolean fillTo(int needed) throws IOException { // The copy happens only when bytes are actually left over -- the pipelining // case. The ordinary request-per-read path copies nothing. + /// Reads the next `needed` body bytes into `spool` -- what is already + /// buffered first, then the socket one block at a time -- so an upload of + /// any size costs one block of heap. The same rate floor and in-flight + /// budget as fillTo, which the block is charged against. Leaves anything + /// past the body (a pipelined request) in the socket. False when the peer + /// went away. + boolean spoolTo(Spool spool, int needed) throws IOException { + if (borrowed) { + // Before anything moves: see fillTo. + detachPreservingOffsets(); + } + int keep = Math.min(available(), needed); + if (keep > 0 && !spool.write(buffer, pos, keep)) { + throw new ProtocolException(413, "request body too large"); + } + pos += keep; + int left = needed - keep; + if (left == 0) { + return true; + } + int block = Math.min(left, BODY_CHUNK_BYTES * 4); + long charged = block; + try { + if (http1UploadBytes.addAndGet(block) > MAX_HTTP1_UPLOAD_BYTES) { + throw new ProtocolException(503, "too many uploads in flight"); + } + byte[] chunk = new byte[block]; + long started = System.currentTimeMillis(); + long allowed = SOCKET_TIMEOUT_MILLIS + (long) left * 1000L / MIN_BODY_BYTES_PER_SECOND; + while (left > 0) { + if (System.currentTimeMillis() - started > allowed) { + throw new ProtocolException(408, "the request body did not arrive in time"); + } + int n = readFrom(fd, session, chunk, 0, Math.min(chunk.length, left)); + if (n <= 0) { + return false; + } + if (!spool.write(chunk, 0, n)) { + throw new ProtocolException(413, "request body too large"); + } + left -= n; + } + return true; + } finally { + http1UploadBytes.addAndGet(-charged); + } + } + /// Take a private copy of the whole borrowed buffer, keeping every index the /// same, so anything already parsed out of it (a Request's header slices) /// keeps pointing at the right bytes. @@ -6139,6 +6385,9 @@ private void serveOneRelease(int fd) { return; } } finally { + // The exchange is over however it ended, so a spooled body's + // file goes now rather than when the next request arrives. + request.release(); inFlightRequests.decrementAndGet(); SERVING_FD.set(null); if (span != null) { @@ -6328,6 +6577,7 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) // startup hook costs an atomic store on a path that is already // creating a session. Http2.setMaxBodyBytes(MAX_OPEN_H2_BODY_BYTES); + Http2.setBodyLimits(MAX_SPOOLED_BODY_BYTES, SPOOL_THRESHOLD, SPOOL_DIRECTORY); Http2.setMaxFileBodies(MAX_OPEN_H2_FILES); h2 = Http2.create(); http2Sessions.put(Integer.valueOf(fd), h2); @@ -6407,6 +6657,41 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) continue; } byte[] h2RequestBody = stream.getBody(); + // A body past the spool threshold arrived in a file (the native + // receiver spools under the same limits as HTTP/1). Binary and + // unencoded, it stays there for getBodyStream, exactly as HTTP/1 + // keeps it; otherwise it is text or compressed, which HTTP/1 holds + // in memory under MAX_BODY_BYTES, so it is read back under that + // same rule and decoded the ordinary way below. + Spool h2Spool = null; + int h2BodyFd = stream.takeBodyFd(); + if (h2BodyFd >= 0) { + h2Spool = Spool.adopt(h2BodyFd, stream.getBodyLength()); + Object h2Type = headers.get("content-type"); + Object h2Coding = headers.get("content-encoding"); + if (!isSpoolable(h2Type == null ? null : h2Type.toString(), + h2Coding == null ? null : h2Coding.toString())) { + byte[] loaded = null; + if (h2Spool.length() <= MAX_BODY_BYTES) { + try { + loaded = h2Spool.toBytes(); + } catch (IOException unreadable) { + loaded = null; + } + } + h2Spool.close(); + h2Spool = null; + if (loaded == null) { + if (!h2.respond(stream.getId(), 413, "text/plain", new ArrayList(), + asciiBytes("request body too large"))) { + h2.respond(stream.getId(), 413, "text/plain", new ArrayList(), null); + } + requestsServed.incrementAndGet(); + continue; + } + h2RequestBody = loaded; + } + } // The body read through the same decision HTTP/1 makes -- gzip, // then text or bytes by Content-Type -- so a handler cannot tell // which protocol carried it. Decided here rather than in @@ -6453,6 +6738,9 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) h2.respond(stream.getId(), 400, "text/plain", new ArrayList(), null); } requestsServed.incrementAndGet(); + if (h2Spool != null) { + h2Spool.close(); + } continue; } if (targetHasFragment(stream.getPath())) { @@ -6465,6 +6753,9 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) h2.respond(stream.getId(), 400, "text/plain", new ArrayList(), null); } requestsServed.incrementAndGet(); + if (h2Spool != null) { + h2Spool.close(); + } continue; } if (!isKnownMethod(stream.getMethod())) { @@ -6481,10 +6772,16 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) h2.respond(stream.getId(), 501, "text/plain", new ArrayList(), null); } requestsServed.incrementAndGet(); + if (h2Spool != null) { + h2Spool.close(); + } continue; } Request request = new Request(stream.getMethod(), stream.getPath(), "HTTP/2", headers, h2Text, h2Binary); + if (h2Spool != null) { + request.setBody(h2Spool); + } request.peerFd = fd; request.overTls = tls != null; request.forwarded = forwardedHeaders; @@ -6591,6 +6888,7 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) boolean noBody = headOnly || statusForbidsBody(response.status); if (response.bodyWriter != null && !noBody) { if (!h2.respondStream(stream.getId(), response.status, contentType, extra)) { + response.discard(); h2.respond(stream.getId(), 503, "text/plain", refusalHeaders(), null); fallbackStatus = 503; } else { @@ -6681,7 +6979,9 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) // and the second one then lands on whatever took it. if (headOnly && response.bodyWriter == null && !statusForbidsLength(response.status)) { long described; - if (response.fileFd >= 0) { + if (response.describedLength >= 0) { + described = response.describedLength; + } else if (response.fileFd >= 0) { described = response.fileLength; } else if (response.hasDeferredJson) { // respondJson leaves the value UNSERIALISED so the @@ -6697,6 +6997,10 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) } extra.add("content-length: " + described); } + // After the length above, which reads whether there was a + // writer: a bodiless answer never runs it, so it is closed. + // Only the writer -- responseBodyFor closes a file itself. + response.releaseWriter(); byte[] h2Body = responseBodyFor(response, noBody); int bodyBytes = h2Body == null ? 0 : h2Body.length; // The RESERVATION is the check. Testing the counter here and @@ -6767,6 +7071,7 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) // while this thread was still about to call into nghttp2 -- so the // deadline sweep could close the descriptor and free the session // underneath it, which truncates the response at best. + request.release(); inFlightRequests.decrementAndGet(); SERVING_FD.set(null); SERVING_H2.set(null); @@ -8043,6 +8348,9 @@ private Request readRequest(Conn conn, byte[] scratch) throws IOException { // past a proxy that believes one and a server that believes the other. throw new ProtocolException(400, "both Content-Length and Transfer-Encoding"); } + boolean spoolable = (chunked || contentLength != null) + && isSpoolable(request.getHeader("Content-Type"), + request.getHeader("Content-Encoding")); // BEFORE THE EXPECTATION IS ANSWERED, because 100 Continue is an // invitation to send a body and these are the two reasons this server @@ -8062,7 +8370,7 @@ private Request readRequest(Conn conn, byte[] scratch) throws IOException { if (declaredLength < 0) { throw new ProtocolException(400, "malformed Content-Length"); } - if (declaredLength > MAX_BODY_BYTES) { + if (declaredLength > (spoolable ? MAX_SPOOLED_BODY_BYTES : MAX_BODY_BYTES)) { throw new ProtocolException(413, "request body too large"); } } @@ -8095,8 +8403,33 @@ private Request readRequest(Conn conn, byte[] scratch) throws IOException { String body = null; byte[] binary = null; + if (spoolable && (chunked || declaredLength > SPOOL_THRESHOLD)) { + // Written to the spool as it arrives, so the heap holds one read's + // worth whatever the client declared. Closed here on every way out + // but success; on success the Request owns it and the serving loop + // closes it when the exchange ends. + Spool spool = new Spool(SPOOL_THRESHOLD, MAX_SPOOLED_BODY_BYTES, SPOOL_DIRECTORY); + boolean kept = false; + try { + boolean complete = chunked ? readChunked(conn, scratch, spool) != null + : conn.spoolTo(spool, declaredLength); + if (!complete) { + return null; + } + if (spool.length() == 0) { + return request; + } + request.setBody(spool); + kept = true; + return request; + } finally { + if (!kept) { + spool.close(); + } + } + } if (chunked) { - byte[] decoded = readChunked(conn, scratch); + byte[] decoded = readChunked(conn, scratch, null); if (decoded == null) { return null; } @@ -8287,7 +8620,11 @@ static byte[] gunzip(byte[] data, int offset, int length) throws ProtocolExcepti /// zero-length chunk. The trailer section after it is consumed and discarded -- /// ignoring trailers is allowed, but leaving them in the stream would /// desynchronise the next request on a keep-alive connection. - private byte[] readChunked(Conn conn, byte[] scratch) throws IOException { + /// With a `spool`, the decoded body is written to it chunk by chunk -- each + /// chunk as it arrives, never a whole chunk in the connection buffer -- and + /// under MAX_SPOOLED_BODY_BYTES; the answer is then an empty array, or null + /// for a peer that went away. + private byte[] readChunked(Conn conn, byte[] scratch, Spool spool) throws IOException { // Charged against the SAME process-wide budget the fixed-length path uses. // Bounding only that path left this one open: a chunked body is capped per // request at MAX_BODY_BYTES and by nothing at all across requests, so @@ -8429,7 +8766,7 @@ private byte[] readChunked(Conn conn, byte[] scratch) throws IOException { } conn.pos = trailerEnd + 2; if (blank) { - return usedBytes(body); + return spool != null ? EMPTY_BODY : usedBytes(body); } } } @@ -8439,6 +8776,41 @@ private byte[] readChunked(Conn conn, byte[] scratch) throws IOException { // chunk. Four bytes and a "7ffffffd" header was enough for an // unauthenticated client to take the process out. Both sides here are // non-negative, so there is nothing left to overflow. + if (spool != null) { + if (size > MAX_SPOOLED_BODY_BYTES - spool.length()) { + throw new ProtocolException(413, "chunked body too large"); + } + int left = size; + while (left > 0) { + if (conn.available() == 0) { + requireChunkedProgress(started, + (int) Math.min(Integer.MAX_VALUE, spool.length())); + reserveUploadUpTo(charged, conn.available()); + if (!conn.fill(scratch)) { + return null; + } + continue; + } + int take = Math.min(left, conn.available()); + if (!spool.write(conn.buffer, conn.pos, take)) { + throw new ProtocolException(413, "chunked body too large"); + } + conn.pos += take; + left -= take; + } + while (conn.available() < 2) { + requireChunkedProgress(started, + (int) Math.min(Integer.MAX_VALUE, spool.length())); + if (!conn.fill(scratch)) { + return null; + } + } + if (conn.buffer[conn.pos] != '\r' || conn.buffer[conn.pos + 1] != '\n') { + throw new ProtocolException(400, "malformed chunk terminator"); + } + conn.pos += 2; + continue; + } if (size > MAX_BODY_BYTES - body.length()) { throw new ProtocolException(413, "chunked body too large"); } @@ -8661,6 +9033,9 @@ private void writeHeadAndBody(Conn conn, int fd, long session, Response response if (noBody && !headOnly) { bodyLength = 0; } + if (headOnly && !streamed && response.describedLength >= 0) { + bodyLength = response.describedLength; + } // Assembled into the connection's own buffer, as bytes, with no // intermediate String. See Conn.out: the StringBuilder-to-String-to-bytes @@ -8773,8 +9148,15 @@ private void writeHeadAndBody(Conn conn, int fd, long session, Response response } if (streamed) { - writeTo(fd, session, conn.out, 0, conn.outLength); - if (!noBody) { + try { + writeTo(fd, session, conn.out, 0, conn.outLength); + } catch (IOException | RuntimeException headFailed) { + response.discard(); + throw headFailed; + } + if (noBody) { + response.discard(); + } else { ResponseOutput output = new ResponseOutput(fd, session, chunked, null, 0); //NOPMD CloseResource - complete flushes on success; failure must abort and the connection owns the socket. response.bodyWriter.writeTo(output); output.complete(); diff --git a/vm/backend/src/com/codename1/backend/Multipart.java b/vm/backend/src/com/codename1/backend/Multipart.java index 07383345f21..2143c0a3bc2 100644 --- a/vm/backend/src/com/codename1/backend/Multipart.java +++ b/vm/backend/src/com/codename1/backend/Multipart.java @@ -82,15 +82,108 @@ static boolean isForm(String contentType) { /// The parts of `body`, in order. static List parse(byte[] body, String boundary) { + return parse(new ArraySource(body), boundary); + } + + /// The parts of a body spooled to disk, in order, parsed in place: each part + /// is a slice of the spool, so a large upload is never read into memory to + /// be split, and its file parts are read through [HttpServer.Part#getInputStream]. + static List parse(Spool body, String boundary) { + return parse(new SpoolSource(body), boundary); + } + + /// What the parser reads: a body as an array, or as a spool it reads through + /// a window. Positions are longs because a spooled body may pass 2 GiB. + abstract static class Source { + abstract long length(); + + abstract int at(long position); + + abstract HttpServer.Part part(String name, String filename, String contentType, + Map headers, long offset, long length); + } + + private static final class ArraySource extends Source { + private final byte[] data; + + ArraySource(byte[] data) { + this.data = data; + } + + @Override + long length() { + return data.length; + } + + @Override + int at(long position) { + return data[(int) position]; + } + + @Override + HttpServer.Part part(String name, String filename, String contentType, Map headers, + long offset, long length) { + byte[] copy = new byte[(int) length]; + System.arraycopy(data, (int) offset, copy, 0, copy.length); + return new HttpServer.Part(name, filename, contentType, headers, copy); + } + } + + /// Reads a spool through one 64 KiB window, which the parser's forward scans + /// keep hitting; the boundary search looks back at most a delimiter's length. + private static final class SpoolSource extends Source { + private final Spool spool; + private final byte[] window = new byte[64 * 1024]; + private long windowStart = -1; + private int windowLength; + + SpoolSource(Spool spool) { + this.spool = spool; + } + + @Override + long length() { + return spool.length(); + } + + @Override + int at(long position) { + if (position < windowStart || position >= windowStart + windowLength + || windowStart < 0) { + // Starting a little before the position, so a scan that steps + // back over a delimiter does not reload. + long start = Math.max(0, position - 128); + try { + windowLength = spool.read(start, window); + } catch (java.io.IOException err) { + throw new IllegalStateException("could not read the spooled body: " + err, err); + } + windowStart = start; + if (position >= windowStart + windowLength) { + throw new IllegalStateException("the spooled body ended early"); + } + } + return window[(int) (position - windowStart)]; + } + + @Override + HttpServer.Part part(String name, String filename, String contentType, Map headers, + long offset, long length) { + return new HttpServer.Part(name, filename, contentType, headers, spool, offset, + length); + } + } + + static List parse(Source body, String boundary) { byte[] delimiter = ascii("--" + boundary); List parts = new ArrayList(); - int pos = openingDelimiter(body, delimiter); + long pos = openingDelimiter(body, delimiter); if (pos < 0) { throw new IllegalArgumentException("the multipart body has no boundary"); } while (true) { pos += delimiter.length; - if (pos + 1 < body.length && body[pos] == '-' && body[pos + 1] == '-') { + if (pos + 1 < body.length() && body.at(pos) == '-' && body.at(pos + 1) == '-') { if (!closeEnds(body, pos + 2)) { throw new IllegalArgumentException("the closing multipart boundary is " + "followed by more than padding and CRLF"); @@ -98,7 +191,7 @@ static List parse(byte[] body, String boundary) { return parts; } // Transport padding (RFC 2046) is allowed between a boundary and its CRLF. - while (pos < body.length && (body[pos] == ' ' || body[pos] == '\t')) { + while (pos < body.length() && (body.at(pos) == ' ' || body.at(pos) == '\t')) { pos++; } if (!crlfAt(body, pos)) { @@ -113,13 +206,13 @@ static List parse(byte[] body, String boundary) { // The limit is on the whole header block, counted from its start. Per // line, it let one part carry hundreds of thousands of short headers -- // each line parsed and stored -- inside the body limit. - int headersStart = pos; + long headersStart = pos; while (!crlfAt(body, pos)) { - int end = indexOfCrLf(body, pos); + long end = indexOfCrLf(body, pos); if (end < 0 || end - headersStart > MAX_PART_HEADER_BYTES) { throw new IllegalArgumentException("a multipart part's headers never end"); } - String line = latin1(body, pos, end - pos); + String line = latin1(body, pos, (int) (end - pos)); int colon = line.indexOf(':'); if (colon <= 0) { throw new IllegalArgumentException("a multipart part has a malformed header"); @@ -133,12 +226,10 @@ static List parse(byte[] body, String boundary) { closing[0] = '\r'; closing[1] = '\n'; System.arraycopy(delimiter, 0, closing, 2, delimiter.length); - int next = closingDelimiter(body, closing, pos); + long next = closingDelimiter(body, closing, pos); if (next < 0) { throw new IllegalArgumentException("a multipart part is never closed"); } - byte[] data = new byte[next - pos]; - System.arraycopy(body, pos, data, 0, data.length); String disposition = (String) headers.get("content-disposition"); // RFC 7578 4.2: every part of a form says Content-Disposition: form-data // and names its field. One that does not is a malformed form, not a @@ -152,8 +243,8 @@ static List parse(byte[] body, String boundary) { throw new IllegalArgumentException("a multipart/form-data part names no field"); } String filename = parameter(afterType(disposition), "filename"); - parts.add(new HttpServer.Part(name, filename, (String) headers.get("content-type"), - headers, data)); + parts.add(body.part(name, filename, (String) headers.get("content-type"), + headers, pos, next - pos)); pos = next + 2; } } @@ -163,16 +254,16 @@ static List parse(byte[] body, String boundary) { /// optional transport padding and CRLF (RFC 2046 5.1.1). A part's own bytes /// may contain `CRLF--boundary` followed by anything else -- a binary file /// can -- and taking that for the end of the part refused a legal upload. - private static int closingDelimiter(byte[] body, byte[] closing, int from) { - int at = indexOf(body, closing, from); + private static long closingDelimiter(Source body, byte[] closing, long from) { + long at = indexOf(body, closing, from); while (at >= 0) { - int after = at + closing.length; - if (after + 1 < body.length && body[after] == '-' && body[after + 1] == '-' + long after = at + closing.length; + if (after + 1 < body.length() && body.at(after) == '-' && body.at(after + 1) == '-' && closeEnds(body, after + 2)) { return at; } - int p = after; - while (p < body.length && (body[p] == ' ' || body[p] == '\t')) { + long p = after; + while (p < body.length() && (body.at(p) == ' ' || body.at(p) == '\t')) { p++; } if (crlfAt(body, p)) { @@ -188,17 +279,17 @@ && closeEnds(body, after + 2)) { /// 5.1.1). Any other `--boundary` is preamble text: taking the first substring /// match accepted `prefix--b` as an opening and refused a valid preamble that /// merely mentioned the boundary before the real one. - private static int openingDelimiter(byte[] body, byte[] delimiter) { - int at = indexOf(body, delimiter, 0); + private static long openingDelimiter(Source body, byte[] delimiter) { + long at = indexOf(body, delimiter, 0); while (at >= 0) { - boolean lineStart = at == 0 || (at >= 2 && body[at - 2] == '\r' && body[at - 1] == '\n'); - int after = at + delimiter.length; + boolean lineStart = at == 0 || (at >= 2 && body.at(at - 2) == '\r' && body.at(at - 1) == '\n'); + long after = at + delimiter.length; boolean complete; - if (after + 1 < body.length && body[after] == '-' && body[after + 1] == '-') { + if (after + 1 < body.length() && body.at(after) == '-' && body.at(after + 1) == '-') { complete = closeEnds(body, after + 2); } else { - int p = after; - while (p < body.length && (body[p] == ' ' || body[p] == '\t')) { + long p = after; + while (p < body.length() && (body.at(p) == ' ' || body.at(p) == '\t')) { p++; } complete = crlfAt(body, p); @@ -219,12 +310,12 @@ private static boolean isFormData(String disposition) { /// Whether the close delimiter whose `--` ends just before `pos` really ends /// there: transport padding, then the end of the body or the CRLF that starts /// the epilogue (RFC 2046 5.1.1). `--b--garbage` is not a close delimiter. - private static boolean closeEnds(byte[] body, int pos) { - int p = pos; - while (p < body.length && (body[p] == ' ' || body[p] == '\t')) { + private static boolean closeEnds(Source body, long pos) { + long p = pos; + while (p < body.length() && (body.at(p) == ' ' || body.at(p) == '\t')) { p++; } - return p == body.length || crlfAt(body, p); + return p == body.length() || crlfAt(body, p); } /// The value of `name` in a urlencoded form, or null. `+` is a space, as the @@ -381,13 +472,13 @@ static String parameter(String params, String name) { return null; } - private static boolean crlfAt(byte[] data, int pos) { - return pos + 1 < data.length && data[pos] == '\r' && data[pos + 1] == '\n'; + private static boolean crlfAt(Source data, long pos) { + return pos + 1 < data.length() && data.at(pos) == '\r' && data.at(pos + 1) == '\n'; } - private static int indexOfCrLf(byte[] data, int from) { - for (int iter = from ; iter + 1 < data.length ; iter++) { - if (data[iter] == '\r' && data[iter + 1] == '\n') { + private static long indexOfCrLf(Source data, long from) { + for (long iter = from ; iter + 1 < data.length() ; iter++) { + if (data.at(iter) == '\r' && data.at(iter + 1) == '\n') { return iter; } } @@ -399,7 +490,7 @@ private static int indexOfCrLf(byte[] data, int from) { /// a naive search restarted the comparison at every matching first byte, so a /// boundary of dashes over a body of dashes cost hundreds of millions of /// comparisons per request before it was refused. - private static int indexOf(byte[] data, byte[] needle, int from) { + private static long indexOf(Source data, byte[] needle, long from) { int[] fallback = new int[needle.length]; int k = 0; for (int iter = 1 ; iter < needle.length ; iter++) { @@ -412,11 +503,11 @@ private static int indexOf(byte[] data, byte[] needle, int from) { fallback[iter] = k; } k = 0; - for (int iter = Math.max(from, 0) ; iter < data.length ; iter++) { - while (k > 0 && data[iter] != needle[k]) { + for (long iter = Math.max(from, 0) ; iter < data.length() ; iter++) { + while (k > 0 && data.at(iter) != needle[k]) { k = fallback[k - 1]; } - if (data[iter] == needle[k]) { + if (data.at(iter) == needle[k]) { k++; } if (k == needle.length) { @@ -434,10 +525,10 @@ private static byte[] ascii(String value) { return out; } - private static String latin1(byte[] data, int offset, int length) { + private static String latin1(Source data, long offset, int length) { char[] out = new char[length]; for (int iter = 0 ; iter < length ; iter++) { - out[iter] = (char) (data[offset + iter] & 0xff); + out[iter] = (char) (data.at(offset + iter) & 0xff); } return new String(out); } diff --git a/vm/backend/src/com/codename1/backend/Spool.java b/vm/backend/src/com/codename1/backend/Spool.java new file mode 100644 index 00000000000..e308d2ad66b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Spool.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import java.io.IOException; +import java.io.InputStream; + +/// Bytes held in memory up to a threshold and in an unlinked temporary file past +/// it: a large request body or multipart part, kept to a fixed amount of heap. +/// +/// The file has no name from the moment it exists (FileIo.createTemp), so +/// [#close] is the whole of the cleanup and a crash leaves nothing behind. The +/// owner -- the request that read the body -- closes it when its exchange ends, +/// however it ends. Any number of streams from [#open] may read it at once: they +/// read by position and share nothing but the descriptor. +final class Spool { + private final int threshold; + private final long limit; + private final String directory; + private ByteSink memory; + private int fd = -1; + private long length; + private boolean closed; + + /// @param threshold bytes kept in memory before the content moves to disk + /// @param limit the most this spool accepts; [#write] answers false past it + /// @param directory where the file goes, or null for the system default + Spool(int threshold, long limit, String directory) { + this.threshold = threshold; + this.limit = limit; + this.directory = directory; + this.memory = new ByteSink(Math.max(1, Math.min(threshold, 8192))); + } + + /// A spool over a file someone else already wrote -- the HTTP/2 receiver, + /// which spools natively -- taking ownership of its descriptor. + static Spool adopt(int fd, long length) { + Spool spool = new Spool(0, length, null); + spool.memory = null; + spool.fd = fd; + spool.length = length; + return spool; + } + + /// Appends a slice. False when it would take the content past the limit, + /// with nothing written; IOException when the disk refused it. + boolean write(byte[] data, int offset, int count) throws IOException { + if (closed) { + throw new IOException("spool is closed"); + } + if (count > limit - length) { + return false; + } + if (fd < 0 && length + count > threshold) { + fd = FileIo.createTemp(directory); + if (fd < 0) { + throw new IOException("could not create a spool file in " + + (directory == null ? "the temporary directory" : directory)); + } + if (memory.length() > 0 && !FileIo.write(fd, memory.bytes(), 0, memory.length())) { + throw new IOException("could not write the spool file"); + } + memory = null; + } + if (fd >= 0) { + if (count > 0 && !FileIo.write(fd, data, offset, count)) { + throw new IOException("could not write the spool file"); + } + } else { + memory.put(data, offset, count); + } + length += count; + return true; + } + + long length() { + return length; + } + + boolean isOnDisk() { + return fd >= 0; + } + + /// The whole content as one array. The caller decides whether that is + /// affordable; a spool past [Integer#MAX_VALUE] cannot be one. + byte[] toBytes() throws IOException { + if (length > Integer.MAX_VALUE - 8) { + throw new IOException("content of " + length + " bytes cannot be one array"); + } + return toBytes(0, (int) length); + } + + /// A new stream over the content from its start. Reading it after [#close] + /// throws. + InputStream open() { + return new Reader(0, length); + } + + /// A stream over `count` bytes from `offset`: one multipart part. + InputStream open(long offset, long count) { + if (offset < 0 || count < 0 || offset > length - count) { + throw new IndexOutOfBoundsException(offset + "+" + count + " of " + length); + } + return new Reader(offset, offset + count); + } + + /// Fills as much of `target` as there is from `position`; the count. + int read(long position, byte[] target) throws IOException { + int want = (int) Math.max(0, Math.min(target.length, length - position)); + int at = 0; + while (at < want) { + int n = readAt(position + at, target, at, want - at); + at += n; + } + return want; + } + + /// One read of at most `count` bytes at `position`, which must be inside. + private int readAt(long position, byte[] target, int offset, int count) throws IOException { + if (closed) { + throw new IOException("the request that owned this content has ended"); + } + if (fd < 0) { + System.arraycopy(memory.bytes(), (int) position, target, offset, count); + return count; + } + int n = FileIo.pread(fd, target, offset, count, position); + if (n <= 0) { + throw new IOException("could not read the spool file"); + } + return n; + } + + /// `count` bytes from `offset` as one array. + byte[] toBytes(long offset, int count) throws IOException { + byte[] out = new byte[count]; + int at = 0; + while (at < count) { + at += readAt(offset + at, out, at, count - at); + } + return out; + } + + void close() { + closed = true; + memory = null; + if (fd >= 0) { + FileIo.close(fd); + fd = -1; + } + } + + private final class Reader extends InputStream { + private long position; + private final long end; + + Reader(long start, long end) { + this.position = start; + this.end = end; + } + + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n <= 0 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] target, int offset, int count) throws IOException { + if (count == 0) { + return 0; + } + long left = end - position; + if (left <= 0) { + if (closed) { + throw new IOException("the request that owned this content has ended"); + } + return -1; + } + int n = readAt(position, target, offset, (int) Math.min(count, left)); + position += n; + return n; + } + + @Override + public long skip(long count) { + long step = Math.max(0, Math.min(count, end - position)); + position += step; + return step; + } + + @Override + public int available() { + long left = end - position; + return left > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0, left); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/WebClient.java b/vm/backend/src/com/codename1/backend/WebClient.java index 79487508327..0f45e63c4ba 100644 --- a/vm/backend/src/com/codename1/backend/WebClient.java +++ b/vm/backend/src/com/codename1/backend/WebClient.java @@ -196,20 +196,50 @@ public Web.Result request(String method, String url, List headers, byte[] body) try { return Web.request(this, method, url, headers, body); } finally { - Object released = null; - synchronized (this) { - inFlight--; - if (closed && inFlight == 0) { - released = transport; - transport = null; - } + exchangeEnded(); + } + } + + /// As [Web#open(String, String, List, long)], through this client: its + /// certificate, pool and connect and read timeouts. The exchange counts as a + /// request in flight until it is closed, so [#close] waits for it. + public Web.Exchange open(String method, String url, List headers, long requestLength) + throws IOException { + synchronized (this) { + if (closed) { + throw new IOException("This WebClient has been closed"); } - if (released != null) { - Web.closeTransport(released); + used = true; + inFlight++; + } + boolean opened = false; + try { + Web.Exchange exchange = Web.open(this, method, url, headers, requestLength); + opened = true; + return exchange; + } finally { + if (!opened) { + exchangeEnded(); } } } + /// One request or exchange is over; the last one out after close() releases + /// the transport. + void exchangeEnded() { + Object released = null; + synchronized (this) { + inFlight--; + if (closed && inFlight == 0) { + released = transport; + transport = null; + } + } + if (released != null) { + Web.closeTransport(released); + } + } + /// Releases the client's pooled connections. A request made after this /// fails; one already running finishes first, and the last of them /// releases the connections. diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java index 3c6abdce945..d4de062536a 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java @@ -50,6 +50,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; @@ -2384,6 +2385,148 @@ private int h2StatusFor(int onPort, String path, String method, String authority } } + @Test + @DisplayName("an HTTP/2 body that crosses the limit is answered 413, never served short") + void http2BodyPastTheLimitIsAnswered413() throws Exception { + // The reader refuses the DATA chunk that crosses the limit, which makes + // nghttp2 reset the stream -- but the frames the client already sent are + // still delivered, the last with END_STREAM, and that used to complete + // the request: the handler answered 200 for the first 8 MB of a 9 MB + // upload. Over HTTP/1.1 the same body is a 413, so it must be here too, + // as a status the client reads rather than a bare reset. + assertEquals(413, h2Upload(port, "/echo", "text/plain", 9 * 1024 * 1024), + "a body past the limit must be answered 413"); + // The control: under the limit it is still served, whole. + assertEquals(200, h2Upload(port, "/echo", "text/plain", 3 * 1000 * 1000), + "a body under the limit must still be served"); + } + + /** + * POSTs `size` bytes of 'a' as one h2c stream, honouring the server's flow + * control windows, and returns the response's :status (-1 when the stream + * ended without one). Stops sending once the server has answered. + */ + private int h2Upload(int onPort, String path, String contentType, int size) + throws Exception { + final Socket socket = new Socket(); + socket.connect(new InetSocketAddress("127.0.0.1", onPort), 5000); + socket.setSoTimeout(30000); + final Object lock = new Object(); + final long[] windows = {65535, 65535}; // connection, stream 1 + final int[] result = {-1, 0}; // status, finished + try { + final OutputStream out = socket.getOutputStream(); + out.write("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + out.write(frame(4, 0, 0, new byte[0])); + ByteArrayOutputStream block = new ByteArrayOutputStream(); + hpackLiteral(block, ":method", "POST"); + hpackLiteral(block, ":path", path); + hpackLiteral(block, ":scheme", "http"); + hpackLiteral(block, ":authority", "127.0.0.1"); + hpackLiteral(block, "content-type", contentType); + hpackLiteral(block, "content-length", String.valueOf(size)); + out.write(frame(1, 0x04, 1, block.toByteArray())); + out.flush(); + final InputStream in = socket.getInputStream(); + Thread reader = new Thread(new Runnable() { + public void run() { + try { + while (true) { + byte[] header = readExactly(in, 9); + if (header == null) { + break; + } + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) + | (header[2] & 0xff); + int type = header[3] & 0xff; + int flags = header[4] & 0xff; + int stream = ((header[5] & 0x7f) << 24) | ((header[6] & 0xff) << 16) + | ((header[7] & 0xff) << 8) | (header[8] & 0xff); + byte[] payload = length == 0 ? new byte[0] : readExactly(in, length); + if (payload == null) { + break; + } + synchronized (lock) { + if (type == 4 && (flags & 1) == 0) { + for (int at = 0; at + 6 <= payload.length; at += 6) { + int id = ((payload[at] & 0xff) << 8) | (payload[at + 1] & 0xff); + if (id == 4) { + long v = ((payload[at + 2] & 0xffL) << 24) + | ((payload[at + 3] & 0xff) << 16) + | ((payload[at + 4] & 0xff) << 8) + | (payload[at + 5] & 0xff); + windows[1] += v - 65535; + } + } + out.write(frame(4, 1, 0, new byte[0])); + out.flush(); + } else if (type == 8 && payload.length == 4) { + long inc = ((payload[0] & 0x7fL) << 24) | ((payload[1] & 0xff) << 16) + | ((payload[2] & 0xff) << 8) | (payload[3] & 0xff); + windows[stream == 0 ? 0 : 1] += inc; + } else if (type == 1 && stream == 1 && payload.length > 0) { + result[0] = hpackStatus(payload); + if ((flags & 1) != 0) { + result[1] = 1; + } + } else if ((type == 0 && stream == 1 && (flags & 1) != 0) + || (type == 3 && stream == 1) || type == 7) { + result[1] = 1; + } + lock.notifyAll(); + if (result[1] != 0) { + return; + } + } + } + } catch (IOException ended) { + // the socket closed; whatever was seen is the answer + } finally { + synchronized (lock) { + result[1] = 1; + lock.notifyAll(); + } + } + } + }); + reader.setDaemon(true); + reader.start(); + byte[] chunk = new byte[16384]; + Arrays.fill(chunk, (byte) 'a'); + int sent = 0; + long deadline = System.currentTimeMillis() + 30000; + while (sent < size) { + int n; + synchronized (lock) { + while (result[1] == 0 && result[0] < 0 && Math.min(windows[0], windows[1]) <= 0 + && System.currentTimeMillis() < deadline) { + lock.wait(200); + } + if (result[1] != 0 || result[0] >= 0 || System.currentTimeMillis() >= deadline) { + break; + } + n = (int) Math.min(Math.min(chunk.length, size - sent), + Math.min(windows[0], windows[1])); + windows[0] -= n; + windows[1] -= n; + boolean last = sent + n == size; + out.write(frame(0, last ? 1 : 0, 1, n == chunk.length ? chunk + : Arrays.copyOf(chunk, n))); + out.flush(); + } + sent += n; + } + synchronized (lock) { + while (result[1] == 0 && System.currentTimeMillis() < deadline) { + lock.wait(200); + } + return result[0]; + } + } finally { + socket.close(); + } + } + @Test @DisplayName("an HTTP/2 body past the limit is refused instead of invited") void http2RefusesAnOversizedBodyBeforeInvitingIt() throws Exception {