From 7d51ef9b5d2a962b149f28872776ee44de524203 Mon Sep 17 00:00:00 2001 From: Mohsen Zainalpour Date: Fri, 16 Oct 2026 17:10:52 -0400 Subject: [PATCH] feat: expose rift 0.22.0 body and journal limits, and RecordedRequest.bodyOmitted() rift 0.22.0 adds three engine limits the SDK could not set: the largest body (413 past it), the memory buffered bodies share (503 past it) and each imposter's recorded-request budget. SpawnOptions passes them as CLI flags, gated on the declared engine version; EmbeddedOptions sends them to rift_serve_admin, gated on advertisement, and serves the admin plane at start because the engine applies them only to imposters created afterwards; RiftContainer sets the engine's env vars, gated on the image tag. All three refuse the combinations the engine refuses, including a budget below the engine's own default ceiling, so a bad setup fails at the builder rather than at engine start. A request larger than the journal budget is recorded without its body; it read as an empty body with no sign why. RecordedRequest.bodyOmitted() now reports the engine's _bodyOmitted flag. Closes #309 --- README.md | 5 +- docs/design/sdk-api.md | 8 ++ docs/testcontainers.md | 9 ++ .../rift/conformance/SpawnBodyLimitsIT.java | 61 ++++++++++ .../io/github/achirdlabs/rift/BodyLimits.java | 63 +++++++++++ .../achirdlabs/rift/EmbeddedOptions.java | 82 +++++++++++++- .../achirdlabs/rift/RecordedRequest.java | 14 +++ .../github/achirdlabs/rift/SpawnOptions.java | 81 ++++++++++++- .../achirdlabs/rift/spawn/RiftProcess.java | 12 ++ .../rift/EmbeddedBodyLimitsTest.java | 57 ++++++++++ .../rift/RecordedRequestBodyOmittedTest.java | 32 ++++++ .../rift/spawn/SpawnBodyLimitsTest.java | 106 ++++++++++++++++++ .../rift/embedded/EmbeddedProvider.java | 16 ++- .../rift/embedded/EmbeddedTransport.java | 6 + .../rift/embedded/LaunchOptionsIT.java | 34 ++++++ .../embedded/ServeOptionsBodyLimitsTest.java | 75 +++++++++++++ .../rift/testcontainers/RiftContainer.java | 106 ++++++++++++++++++ .../RiftContainerBodyLimitsTest.java | 100 +++++++++++++++++ 18 files changed, 859 insertions(+), 8 deletions(-) create mode 100644 rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnBodyLimitsIT.java create mode 100644 rift-java-core/src/main/java/io/github/achirdlabs/rift/BodyLimits.java create mode 100644 rift-java-core/src/test/java/io/github/achirdlabs/rift/EmbeddedBodyLimitsTest.java create mode 100644 rift-java-core/src/test/java/io/github/achirdlabs/rift/RecordedRequestBodyOmittedTest.java create mode 100644 rift-java-core/src/test/java/io/github/achirdlabs/rift/spawn/SpawnBodyLimitsTest.java create mode 100644 rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsBodyLimitsTest.java create mode 100644 rift-java-testcontainers/src/test/java/io/github/achirdlabs/rift/testcontainers/RiftContainerBodyLimitsTest.java diff --git a/README.md b/README.md index 730d40f..be153db 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,10 @@ not the compatibility floor — override with `SpawnOptions.builder().version("X sends (it reaches the engine as `MB_APIKEY`, not on the command line). `metricsPort(n)` moves the engine's Prometheus listener off its default 9090, which two spawned engines would otherwise contend for (the loser runs without metrics, and says so only in its log). `EmbeddedOptions` takes `metricsPort`, `allowInjection` and `requireAdminAuth` for the in-process -admin server the same way. +admin server the same way. Both take rift 0.22.0's body and journal limits too: `maxBodySize` (a +larger body gets `413`), `bodyMemoryBudget` (memory shared by buffered bodies; `503` past it) and +`journalMaxBytes` (recorded-request bytes per imposter; a larger request is recorded without its body, +`RecordedRequest.bodyOmitted()`), all in bytes. ### JUnit 5 diff --git a/docs/design/sdk-api.md b/docs/design/sdk-api.md index fcd98e2..02db996 100644 --- a/docs/design/sdk-api.md +++ b/docs/design/sdk-api.md @@ -195,6 +195,11 @@ public final class SpawnOptions { // --metrics-port; unset = the engine's 9090, which two spawned engines contend for; a taken // port is not fatal — the engine runs without metrics). An MB_APIKEY in env() is refused at // build(), and one inherited from the JVM is not passed on. + // maxBodySize (long bytes >= 1, --max-body-size; over it 413), bodyMemoryBudget (>= 0, 0 = none, + // --body-memory-budget; over it 503 + Retry-After), journalMaxBytes (>= 1, --journal-max-bytes; + // a body larger than it is recorded without it, RecordedRequest.bodyOmitted()): rift >= 0.22.0, + // checked at build(), which also refuses a non-zero budget below the ceiling (the engine's + // 512 MiB default when unset), or a ceiling above its 1 GiB default budget with none set. // Binary resolution order: binaryPath → $RIFT_BINARY_PATH → PATH (rift) → // version cache (~/.cache/rift-java/binaries/rift-/) → download via release // ffi-manifest.json (SHA-256 verified; RIFT_OFFLINE/RIFT_SKIP_BINARY_DOWNLOAD → fail). @@ -213,6 +218,9 @@ public final class EmbeddedOptions { // metricsPort (1..65535; unset = no metrics listener; serves the admin API eagerly, so a taken // port fails Rift.embedded). Each needs the engine to advertise it in EngineInfo.serveOptions // (rift >= 0.17.0), like upstreamTrust. + // maxBodySize / bodyMemoryBudget / journalMaxBytes (OptionalLong; as for SpawnOptions, checked at + // build(); advertised from rift 0.22.0; each serves the admin API eagerly, since the engine + // applies them only to imposters created afterwards). } public sealed interface UpstreamTrust { // outbound TLS trust for proxying/recording (#209) diff --git a/docs/testcontainers.md b/docs/testcontainers.md index 748c8b1..2d0718c 100644 --- a/docs/testcontainers.md +++ b/docs/testcontainers.md @@ -167,6 +167,15 @@ static RiftContainer rift = new RiftContainer() An image tagged with a version older than 0.18.0 is refused when `withUpstreamTrust` is called. See [docs/recording.md](recording.md#recording-an-origin-behind-a-private-ca) for the details. +## Body and journal limits + +`withMaxBodySize(bytes)`, `withBodyMemoryBudget(bytes)` and `withJournalMaxBytes(bytes)` set rift +0.22.0's limits through the engine's `RIFT_MAX_BODY_SIZE`, `RIFT_BODY_MEMORY_BUDGET` and +`RIFT_JOURNAL_MAX_BYTES`. A larger body gets `413`; buffered bodies past the budget get `503` +(`0` is no budget); a request larger than the journal budget is recorded without its body +(`RecordedRequest.bodyOmitted()`). An image tagged with a version older than 0.22.0 is refused, and a +non-zero budget below the body ceiling is refused when the container starts, as the engine would. + ## Using it with the Spring module Publish the container's admin URI as a property and point `@EnableRift(transport = CONNECT)` at it: diff --git a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnBodyLimitsIT.java b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnBodyLimitsIT.java new file mode 100644 index 0000000..69a8b67 --- /dev/null +++ b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnBodyLimitsIT.java @@ -0,0 +1,61 @@ +package io.github.achirdlabs.rift.conformance; + +import io.github.achirdlabs.rift.Imposter; +import io.github.achirdlabs.rift.RecordedRequest; +import io.github.achirdlabs.rift.Rift; +import io.github.achirdlabs.rift.SpawnOptions; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.stream.Stream; + +import static io.github.achirdlabs.rift.conformance.LiveEngine.gatedTo; +import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter; +import static io.github.achirdlabs.rift.dsl.RiftDsl.ok; +import static io.github.achirdlabs.rift.dsl.RiftDsl.onRequest; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** {@code SpawnOptions} body and journal limits reach a spawned rift 0.22.0 engine (#309). Needs {@code RIFT_IT=1}. */ +class SpawnBodyLimitsIT { + + private static final String EMBEDDED_COVERED = "the embedded limits are EmbeddedOptions', covered by LaunchOptionsIT"; + + @TestFactory + Stream maxBodySizeRefusesALargerBody() { + return gatedTo(ConformanceTransport.SPAWN, EMBEDDED_COVERED, "a body over maxBodySize gets 413", () -> { + try (Rift rift = Rift.spawn(SpawnOptions.builder().maxBodySize(1024).build())) { + int port = rift.create(imposter("capped").stub(onRequest().willReturn(ok()))).port(); + assertEquals(413, post(port, 2048).statusCode()); + assertEquals(200, post(port, 512).statusCode()); + } + }); + } + + @TestFactory + Stream journalMaxBytesOmitsALargerBody() { + return gatedTo(ConformanceTransport.SPAWN, EMBEDDED_COVERED, + "a body larger than the journal budget is recorded without it", () -> { + try (Rift rift = Rift.spawn(SpawnOptions.builder().journalMaxBytes(4096).build())) { + Imposter imposter = rift.create(imposter("journal").record().stub(onRequest().willReturn(ok()))); + assertEquals(200, post(imposter.port(), 16_384).statusCode()); + RecordedRequest recorded = imposter.recorded().get(0); + assertTrue(recorded.bodyOmitted(), "recorded with its body (" + recorded.body().length() + " bytes)"); + assertEquals("", recorded.body()); + } + }); + } + + private static HttpResponse post(int port, int bodyBytes) throws Exception { + // a fresh client each time: the engine closes the connection after a 413 + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + return client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/upload")) + .timeout(Duration.ofSeconds(5)).POST(HttpRequest.BodyPublishers.ofString("x".repeat(bodyBytes))).build(), + HttpResponse.BodyHandlers.discarding()); + } +} diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/BodyLimits.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/BodyLimits.java new file mode 100644 index 0000000..2e09bf2 --- /dev/null +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/BodyLimits.java @@ -0,0 +1,63 @@ +package io.github.achirdlabs.rift; + +import java.util.OptionalLong; + +/** + * The checks rift 0.22.0 applies to its body and journal limits (rift#1439, rift#1441), run where + * the SDK takes them so a bad combination fails at the builder rather than at engine start. + */ +final class BodyLimits { + + static final String SINCE = "0.22.0"; + + /** The engine's {@code maxBodySize} when none is set: 512 MiB. */ + static final long DEFAULT_MAX_BODY_SIZE = 512L << 20; + + /** The engine's {@code bodyMemoryBudget} when none is set: 1 GiB. */ + static final long DEFAULT_BUDGET = 1L << 30; + + private BodyLimits() { + } + + static long maxBodySize(long bytes) { + if (bytes < 1) { + throw new IllegalArgumentException("maxBodySize must be at least 1 byte, was " + bytes); + } + return bytes; + } + + static long bodyMemoryBudget(long bytes) { + if (bytes < 0) { + throw new IllegalArgumentException("bodyMemoryBudget must be 0 (unbounded) or more bytes, was " + bytes); + } + return bytes; + } + + static long journalMaxBytes(long bytes) { + if (bytes < 1) { + throw new IllegalArgumentException("journalMaxBytes must be at least 1 byte, was " + bytes); + } + return bytes; + } + + /** + * The engine refuses a non-zero budget below the body ceiling, and fills in whichever of the two + * is unset with its own default (512 MiB ceiling, 1 GiB budget) before comparing them. + */ + static void requireBudgetCoversCeiling(OptionalLong maxBodySize, OptionalLong bodyMemoryBudget) { + if (bodyMemoryBudget.isPresent()) { + long budget = bodyMemoryBudget.getAsLong(); + long ceiling = maxBodySize.orElse(DEFAULT_MAX_BODY_SIZE); + if (budget != 0 && budget < ceiling) { + throw new IllegalArgumentException(maxBodySize.isPresent() + ? "bodyMemoryBudget (" + budget + ") is below maxBodySize (" + ceiling + + "), which the engine refuses; raise it, or set 0 for no budget" + : "bodyMemoryBudget (" + budget + ") is below the engine's default maxBodySize (" + ceiling + + "), which the engine refuses; raise it, lower maxBodySize, or set 0 for no budget"); + } + } else if (maxBodySize.isPresent() && maxBodySize.getAsLong() > DEFAULT_BUDGET) { + throw new IllegalArgumentException("maxBodySize (" + maxBodySize.getAsLong() + ") is above the engine's " + + "default bodyMemoryBudget (" + DEFAULT_BUDGET + "), which the engine refuses; set bodyMemoryBudget too"); + } + } +} diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/EmbeddedOptions.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/EmbeddedOptions.java index 017d987..c7b6cc6 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/EmbeddedOptions.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/EmbeddedOptions.java @@ -4,6 +4,7 @@ import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; +import java.util.OptionalLong; /** * Immutable configuration for {@link Rift#embedded(EmbeddedOptions)}: where to find the @@ -27,6 +28,9 @@ public final class EmbeddedOptions { private final Optional allowInjection; private final Optional requireAdminAuth; private final OptionalInt metricsPort; + private final OptionalLong maxBodySize; + private final OptionalLong bodyMemoryBudget; + private final OptionalLong journalMaxBytes; private EmbeddedOptions( Optional libraryPath, @@ -38,7 +42,10 @@ private EmbeddedOptions( Optional upstreamTrust, Optional allowInjection, Optional requireAdminAuth, - OptionalInt metricsPort) { + OptionalInt metricsPort, + OptionalLong maxBodySize, + OptionalLong bodyMemoryBudget, + OptionalLong journalMaxBytes) { this.libraryPath = libraryPath; this.versionCheck = versionCheck; this.serveAdminEagerly = serveAdminEagerly; @@ -49,6 +56,9 @@ private EmbeddedOptions( this.allowInjection = allowInjection; this.requireAdminAuth = requireAdminAuth; this.metricsPort = metricsPort; + this.maxBodySize = maxBodySize; + this.bodyMemoryBudget = bodyMemoryBudget; + this.journalMaxBytes = journalMaxBytes; } public static Builder builder() { @@ -101,6 +111,21 @@ public OptionalInt metricsPort() { return metricsPort; } + /** The largest body the engine accepts, if set; see {@link Builder#maxBodySize(long)}. */ + public OptionalLong maxBodySize() { + return maxBodySize; + } + + /** The memory buffered bodies may share, if set; see {@link Builder#bodyMemoryBudget(long)}. */ + public OptionalLong bodyMemoryBudget() { + return bodyMemoryBudget; + } + + /** Each imposter's recorded-request budget, if set; see {@link Builder#journalMaxBytes(long)}. */ + public OptionalLong journalMaxBytes() { + return journalMaxBytes; + } + public static final class Builder { private Optional libraryPath = Optional.empty(); @@ -113,6 +138,9 @@ public static final class Builder { private Optional allowInjection = Optional.empty(); private Optional requireAdminAuth = Optional.empty(); private OptionalInt metricsPort = OptionalInt.empty(); + private OptionalLong maxBodySize = OptionalLong.empty(); + private OptionalLong bodyMemoryBudget = OptionalLong.empty(); + private OptionalLong journalMaxBytes = OptionalLong.empty(); private Builder() { } @@ -277,9 +305,59 @@ public Builder metricsPort(int metricsPort) { return this; } + /** + * The largest request, proxied upstream response or admin body the engine accepts, in bytes; + * a larger one gets {@code 413} and the connection is closed. Unset, the engine's own 512 MiB + * applies; a value above 1 GiB needs {@link #bodyMemoryBudget(long)} too. The engine applies + * the limit only to imposters created after it is set, so setting it starts the in-process + * admin server when the engine starts, as {@link #serveAdminEagerly(boolean)} does. Needs an + * engine that advertises the option (rift ≥ 0.22.0), checked as for {@link + * #allowInjection(boolean)}. + * + * @throws IllegalArgumentException if {@code bytes} is below 1 + */ + public Builder maxBodySize(long bytes) { + this.maxBodySize = OptionalLong.of(BodyLimits.maxBodySize(bytes)); + return this; + } + + /** + * The memory every body buffered at once may share, in bytes; past it a request gets {@code + * 503} with {@code Retry-After: 1}. {@code 0} is no budget. Unset, the engine's own 1 GiB + * applies; a non-zero budget must be at least {@link #maxBodySize(long)} (512 MiB when that is + * unset). Starts the admin + * server eagerly and is advertisement-checked, as {@code maxBodySize} is. + * + * @throws IllegalArgumentException if {@code bytes} is negative + */ + public Builder bodyMemoryBudget(long bytes) { + this.bodyMemoryBudget = OptionalLong.of(BodyLimits.bodyMemoryBudget(bytes)); + return this; + } + + /** + * How many bytes of recorded requests each imposter keeps, oldest evicted first. A request + * larger than the whole budget is recorded without its body: see {@link + * RecordedRequest#bodyOmitted()}. Unset, the engine's own 768 MiB applies. Starts the admin + * server eagerly and is advertisement-checked, as {@link #maxBodySize(long)} is. + * + * @throws IllegalArgumentException if {@code bytes} is below 1 + */ + public Builder journalMaxBytes(long bytes) { + this.journalMaxBytes = OptionalLong.of(BodyLimits.journalMaxBytes(bytes)); + return this; + } + + /** + * @throws IllegalArgumentException if a non-zero {@link #bodyMemoryBudget(long)} is below + * {@link #maxBodySize(long)}, or a ceiling above 1 GiB has no + * budget: the engine refuses both + */ public EmbeddedOptions build() { + BodyLimits.requireBudgetCoversCeiling(maxBodySize, bodyMemoryBudget); return new EmbeddedOptions(libraryPath, versionCheck, serveAdminEagerly, adminHost, adminPort, apiKey, - upstreamTrust, allowInjection, requireAdminAuth, metricsPort); + upstreamTrust, allowInjection, requireAdminAuth, metricsPort, maxBodySize, bodyMemoryBudget, + journalMaxBytes); } } } diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/RecordedRequest.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/RecordedRequest.java index 604d031..525c43f 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/RecordedRequest.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/RecordedRequest.java @@ -3,6 +3,7 @@ import io.github.achirdlabs.rift.codec.BodyCodecs; import io.github.achirdlabs.rift.codec.RiftBodyCodec; import io.github.achirdlabs.rift.json.JsonArray; +import io.github.achirdlabs.rift.json.JsonBool; import io.github.achirdlabs.rift.json.JsonNumber; import io.github.achirdlabs.rift.json.JsonObject; import io.github.achirdlabs.rift.json.JsonString; @@ -23,6 +24,10 @@ * reads the same journal, filtered to its flow). Parsed leniently via {@link #read(JsonValue)}: any field * the engine omits comes back empty rather than throwing, since the exact shape recorded requests * take can vary by protocol and engine version. + * + *

{@code body} is {@code ""} for a request sent without one, and for one the engine recorded + * without its body because it was larger than the imposter's whole journal budget: {@link + * #bodyOmitted()} tells the two apart. */ public record RecordedRequest( String method, @@ -42,6 +47,15 @@ public record RecordedRequest( pathParams = Map.copyOf(pathParams); } + /** + * Whether the engine recorded this request without its body, because the body was larger than + * the imposter's whole journal budget ({@code journalMaxBytes}, rift ≥ 0.22.0); {@link #body()} + * is then {@code ""}. Read from the raw entry's {@code _bodyOmitted}. + */ + public boolean bodyOmitted() { + return raw instanceof JsonObject obj && obj.get("_bodyOmitted") instanceof JsonBool omitted && omitted.value(); + } + /** Parses one {@code savedRequests} element. Never throws: unrecognized shapes come back mostly-empty. */ public static RecordedRequest read(JsonValue value) { if (!(value instanceof JsonObject obj)) { diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java index b96e854..c127ae3 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java @@ -8,6 +8,7 @@ import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; +import java.util.OptionalLong; /** * Immutable configuration for {@link Rift#spawn(SpawnOptions)}: which {@code rift} binary to run @@ -36,6 +37,9 @@ public final class SpawnOptions { private final Optional apiKey; private final boolean requireAdminAuth; private final OptionalInt metricsPort; + private final OptionalLong maxBodySize; + private final OptionalLong bodyMemoryBudget; + private final OptionalLong journalMaxBytes; private SpawnOptions(Builder b) { this.binaryPath = b.binaryPath; @@ -55,6 +59,9 @@ private SpawnOptions(Builder b) { this.apiKey = b.apiKey; this.requireAdminAuth = b.requireAdminAuth; this.metricsPort = b.metricsPort; + this.maxBodySize = b.maxBodySize; + this.bodyMemoryBudget = b.bodyMemoryBudget; + this.journalMaxBytes = b.journalMaxBytes; } public static Builder builder() { @@ -133,6 +140,21 @@ public OptionalInt metricsPort() { return metricsPort; } + /** The largest body the engine accepts, if set; see {@link Builder#maxBodySize(long)}. */ + public OptionalLong maxBodySize() { + return maxBodySize; + } + + /** The memory buffered bodies may share, if set; see {@link Builder#bodyMemoryBudget(long)}. */ + public OptionalLong bodyMemoryBudget() { + return bodyMemoryBudget; + } + + /** Each imposter's recorded-request budget, if set; see {@link Builder#journalMaxBytes(long)}. */ + public OptionalLong journalMaxBytes() { + return journalMaxBytes; + } + public static final class Builder { private Optional binaryPath = Optional.empty(); @@ -152,6 +174,9 @@ public static final class Builder { private Optional apiKey = Optional.empty(); private boolean requireAdminAuth = false; private OptionalInt metricsPort = OptionalInt.empty(); + private OptionalLong maxBodySize = OptionalLong.empty(); + private OptionalLong bodyMemoryBudget = OptionalLong.empty(); + private OptionalLong journalMaxBytes = OptionalLong.empty(); private Builder() { } @@ -306,12 +331,55 @@ public Builder metricsPort(int metricsPort) { return this; } + /** + * The largest request, proxied upstream response or admin body the engine accepts + * ({@code --max-body-size}, in bytes); a larger one gets {@code 413} and the connection is + * closed. Unset, the engine's own 512 MiB applies. A value above 1 GiB needs {@link + * #bodyMemoryBudget(long)} too, since the engine refuses a ceiling above its budget. Needs a + * rift engine ≥ 0.22.0, checked against {@link #version(String)} at {@link #build()}. + * + * @throws IllegalArgumentException if {@code bytes} is below 1 + */ + public Builder maxBodySize(long bytes) { + this.maxBodySize = OptionalLong.of(BodyLimits.maxBodySize(bytes)); + return this; + } + + /** + * The memory every body buffered at once may share ({@code --body-memory-budget}, in bytes); + * past it a request gets {@code 503} with {@code Retry-After: 1}. {@code 0} is no budget. + * Unset, the engine's own 1 GiB applies. A non-zero budget must be at least {@link + * #maxBodySize(long)}, or the engine's 512 MiB default when that is unset. Needs a rift + * engine ≥ 0.22.0, checked as for {@code maxBodySize}. + * + * @throws IllegalArgumentException if {@code bytes} is negative + */ + public Builder bodyMemoryBudget(long bytes) { + this.bodyMemoryBudget = OptionalLong.of(BodyLimits.bodyMemoryBudget(bytes)); + return this; + } + + /** + * How many bytes of recorded requests each imposter keeps ({@code --journal-max-bytes}), + * oldest evicted first. A request larger than the whole budget is recorded without its body: + * see {@link RecordedRequest#bodyOmitted()}. Unset, the engine's own 768 MiB applies. Needs a + * rift engine ≥ 0.22.0, checked as for {@link #maxBodySize(long)}. + * + * @throws IllegalArgumentException if {@code bytes} is below 1 + */ + public Builder journalMaxBytes(long bytes) { + this.journalMaxBytes = OptionalLong.of(BodyLimits.journalMaxBytes(bytes)); + return this; + } + /** * @throws IllegalArgumentException if {@link #upstreamTrust(UpstreamTrust)} is an inline PEM, * or is set for an engine {@link #version(String)} older * than 0.18.0; or {@link #requireAdminAuth(boolean)} is on * for one older than 0.17.0; or {@link #env(Map)} carries - * {@code MB_APIKEY}, which belongs in {@link #apiKey(String)} + * {@code MB_APIKEY}, which belongs in {@link #apiKey(String)}; + * or a body or journal limit is set for an engine older + * than 0.22.0, or the budget does not cover the ceiling */ public SpawnOptions build() { if (upstreamTrust.isPresent()) { @@ -332,7 +400,18 @@ public SpawnOptions build() { throw new IllegalArgumentException("requireAdminAuth needs a rift engine >= " + REQUIRE_ADMIN_AUTH_SINCE + ", but version is " + version + " (the older CLI has no --require-admin-auth flag)"); } + requireBodyLimitFlag("maxBodySize", maxBodySize, "--max-body-size"); + requireBodyLimitFlag("bodyMemoryBudget", bodyMemoryBudget, "--body-memory-budget"); + requireBodyLimitFlag("journalMaxBytes", journalMaxBytes, "--journal-max-bytes"); + BodyLimits.requireBudgetCoversCeiling(maxBodySize, bodyMemoryBudget); return new SpawnOptions(this); } + + private void requireBodyLimitFlag(String option, OptionalLong value, String flag) { + if (value.isPresent() && !EngineVersion.atLeast(version, BodyLimits.SINCE)) { + throw new IllegalArgumentException(option + " needs a rift engine >= " + BodyLimits.SINCE + + ", but version is " + version + " (the older CLI has no " + flag + " flag)"); + } + } } } diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/spawn/RiftProcess.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/spawn/RiftProcess.java index 5dc01d3..a30711f 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/spawn/RiftProcess.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/spawn/RiftProcess.java @@ -229,6 +229,18 @@ static List buildCommand(Path binary, SpawnOptions opts, int port, Path cmd.add("--metrics-port"); cmd.add(Integer.toString(metricsPort)); }); + opts.maxBodySize().ifPresent(bytes -> { + cmd.add("--max-body-size"); + cmd.add(Long.toString(bytes)); + }); + opts.bodyMemoryBudget().ifPresent(bytes -> { + cmd.add("--body-memory-budget"); + cmd.add(Long.toString(bytes)); + }); + opts.journalMaxBytes().ifPresent(bytes -> { + cmd.add("--journal-max-bytes"); + cmd.add(Long.toString(bytes)); + }); opts.upstreamTrust().ifPresent(trust -> { if (trust instanceof UpstreamTrust.CaFile file) { cmd.add("--upstream-ca-file"); diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/EmbeddedBodyLimitsTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/EmbeddedBodyLimitsTest.java new file mode 100644 index 0000000..6cb1964 --- /dev/null +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/EmbeddedBodyLimitsTest.java @@ -0,0 +1,57 @@ +package io.github.achirdlabs.rift; + +import org.junit.jupiter.api.Test; + +import java.util.OptionalLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** The body and journal limits {@link EmbeddedOptions} carries to {@code rift_serve_admin} (#309). */ +class EmbeddedBodyLimitsTest { + + @Test + void theLimitsAreCarriedWhenSetAndEmptyOtherwise() { + EmbeddedOptions set = EmbeddedOptions.builder() + .maxBodySize(1024).bodyMemoryBudget(0).journalMaxBytes(4096).build(); + assertEquals(OptionalLong.of(1024), set.maxBodySize()); + assertEquals(OptionalLong.of(0), set.bodyMemoryBudget()); + assertEquals(OptionalLong.of(4096), set.journalMaxBytes()); + + EmbeddedOptions unset = EmbeddedOptions.builder().build(); + assertEquals(OptionalLong.empty(), unset.maxBodySize()); + assertEquals(OptionalLong.empty(), unset.bodyMemoryBudget()); + assertEquals(OptionalLong.empty(), unset.journalMaxBytes()); + } + + @Test + void outOfRangeValuesAreRefusedAtTheSetter() { + assertEquals("maxBodySize must be at least 1 byte, was 0", + assertThrows(IllegalArgumentException.class, () -> EmbeddedOptions.builder().maxBodySize(0)).getMessage()); + assertEquals("maxBodySize must be at least 1 byte, was -5", + assertThrows(IllegalArgumentException.class, () -> EmbeddedOptions.builder().maxBodySize(-5)).getMessage()); + assertEquals("bodyMemoryBudget must be 0 (unbounded) or more bytes, was -1", + assertThrows(IllegalArgumentException.class, () -> EmbeddedOptions.builder().bodyMemoryBudget(-1)).getMessage()); + assertEquals("journalMaxBytes must be at least 1 byte, was 0", + assertThrows(IllegalArgumentException.class, () -> EmbeddedOptions.builder().journalMaxBytes(0)).getMessage()); + } + + @Test + void theBudgetIsCheckedAgainstTheCeilingAtBuild() { + assertEquals("bodyMemoryBudget (10) is below maxBodySize (20), which the engine refuses; raise it, or set 0 for no budget", + assertThrows(IllegalArgumentException.class, + () -> EmbeddedOptions.builder().maxBodySize(20).bodyMemoryBudget(10).build()).getMessage()); + assertEquals("maxBodySize (1073741825) is above the engine's default bodyMemoryBudget (1073741824), which " + + "the engine refuses; set bodyMemoryBudget too", + assertThrows(IllegalArgumentException.class, + () -> EmbeddedOptions.builder().maxBodySize(1_073_741_825L).build()).getMessage()); + assertEquals("bodyMemoryBudget (1024) is below the engine's default maxBodySize (536870912), which the engine " + + "refuses; raise it, lower maxBodySize, or set 0 for no budget", + assertThrows(IllegalArgumentException.class, + () -> EmbeddedOptions.builder().bodyMemoryBudget(1024).build()).getMessage()); + EmbeddedOptions.builder().maxBodySize(1_073_741_824L).build(); + EmbeddedOptions.builder().maxBodySize(20).bodyMemoryBudget(20).build(); + EmbeddedOptions.builder().maxBodySize(5_000_000_000L).bodyMemoryBudget(0).build(); + EmbeddedOptions.builder().maxBodySize(5_000_000_000L).bodyMemoryBudget(5_000_000_000L).build(); + } +} diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/RecordedRequestBodyOmittedTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/RecordedRequestBodyOmittedTest.java new file mode 100644 index 0000000..aba09d0 --- /dev/null +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/RecordedRequestBodyOmittedTest.java @@ -0,0 +1,32 @@ +package io.github.achirdlabs.rift; + +import io.github.achirdlabs.rift.json.JsonValue; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** {@code _bodyOmitted}: rift 0.22.0 records a body larger than the whole journal budget without it (rift#1441). */ +class RecordedRequestBodyOmittedTest { + + @Test + void anOmittedBodyIsFlaggedAndReadsEmpty() { + RecordedRequest r = RecordedRequest.read(JsonValue.parse( + "{\"method\":\"POST\",\"path\":\"/big\",\"_bodyOmitted\":true}")); + assertTrue(r.bodyOmitted()); + assertEquals("", r.body()); + } + + @Test + void aRecordedBodyIsNotFlagged() { + assertFalse(RecordedRequest.read(JsonValue.parse("{\"method\":\"POST\",\"path\":\"/a\",\"body\":\"x\"}")).bodyOmitted()); + assertFalse(RecordedRequest.read(JsonValue.parse("{\"method\":\"POST\",\"_bodyOmitted\":false}")).bodyOmitted()); + } + + @Test + void aNonBooleanFlagIsNotTakenAsOmitted() { + assertFalse(RecordedRequest.read(JsonValue.parse("{\"method\":\"POST\",\"_bodyOmitted\":\"true\"}")).bodyOmitted()); + assertFalse(RecordedRequest.read(JsonValue.parse("[1]")).bodyOmitted()); + } +} diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/spawn/SpawnBodyLimitsTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/spawn/SpawnBodyLimitsTest.java new file mode 100644 index 0000000..ae9de97 --- /dev/null +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/spawn/SpawnBodyLimitsTest.java @@ -0,0 +1,106 @@ +package io.github.achirdlabs.rift.spawn; + +import io.github.achirdlabs.rift.SpawnOptions; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The body and journal limits a spawned rift 0.22.0 engine takes (#309, rift#1439, rift#1441). */ +class SpawnBodyLimitsTest { + + private static List command(SpawnOptions opts) { + return RiftProcess.buildCommand(Path.of("/usr/bin/rift"), opts, 2525, Path.of("/tmp/rift.pid")); + } + + @Test + void theLimitsAreGlobalOptionsBeforeStartInPlainBytes() { + List cmd = command(SpawnOptions.builder() + .maxBodySize(3_000_000_000L).bodyMemoryBudget(4_000_000_000L).journalMaxBytes(65_536).build()); + int start = cmd.indexOf("start"); + assertEquals(cmd.size() - 1, start); + for (String flag : List.of("--max-body-size", "--body-memory-budget", "--journal-max-bytes")) { + assertTrue(cmd.indexOf(flag) >= 0 && cmd.indexOf(flag) < start, flag + " in " + cmd); + } + assertEquals("3000000000", cmd.get(cmd.indexOf("--max-body-size") + 1)); + assertEquals("4000000000", cmd.get(cmd.indexOf("--body-memory-budget") + 1)); + assertEquals("65536", cmd.get(cmd.indexOf("--journal-max-bytes") + 1)); + } + + @Test + void unsetLimitsAddNothing() { + SpawnOptions opts = SpawnOptions.builder().build(); + List cmd = command(opts); + assertFalse(cmd.contains("--max-body-size"), cmd.toString()); + assertFalse(cmd.contains("--body-memory-budget"), cmd.toString()); + assertFalse(cmd.contains("--journal-max-bytes"), cmd.toString()); + assertTrue(opts.maxBodySize().isEmpty() && opts.bodyMemoryBudget().isEmpty() && opts.journalMaxBytes().isEmpty()); + } + + @Test + void aZeroBudgetIsUnboundedAndAccepted() { + List cmd = command(SpawnOptions.builder().maxBodySize(2_000_000_000L).bodyMemoryBudget(0).build()); + assertEquals("0", cmd.get(cmd.indexOf("--body-memory-budget") + 1)); + } + + @Test + void outOfRangeValuesAreRefusedAtTheSetter() { + assertEquals("maxBodySize must be at least 1 byte, was 0", + assertThrows(IllegalArgumentException.class, () -> SpawnOptions.builder().maxBodySize(0)).getMessage()); + assertEquals("bodyMemoryBudget must be 0 (unbounded) or more bytes, was -1", + assertThrows(IllegalArgumentException.class, () -> SpawnOptions.builder().bodyMemoryBudget(-1)).getMessage()); + assertEquals("journalMaxBytes must be at least 1 byte, was 0", + assertThrows(IllegalArgumentException.class, () -> SpawnOptions.builder().journalMaxBytes(0)).getMessage()); + assertEquals("journalMaxBytes must be at least 1 byte, was -1", + assertThrows(IllegalArgumentException.class, () -> SpawnOptions.builder().journalMaxBytes(-1)).getMessage()); + } + + @Test + void aBudgetBelowTheCeilingIsRefusedAsTheEngineWould() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> SpawnOptions.builder().maxBodySize(2048).bodyMemoryBudget(1024).build()); + assertEquals("bodyMemoryBudget (1024) is below maxBodySize (2048), which the engine refuses; " + + "raise it, or set 0 for no budget", e.getMessage()); + } + + @Test + void aBudgetAloneIsCheckedAgainstTheEnginesDefaultCeiling() { + // the engine fills in its 512 MiB maxBodySize before comparing (rift-ffi lib.rs, server.rs body_limits_from) + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> SpawnOptions.builder().bodyMemoryBudget(256L << 20).build()); + assertEquals("bodyMemoryBudget (268435456) is below the engine's default maxBodySize (536870912), which the " + + "engine refuses; raise it, lower maxBodySize, or set 0 for no budget", e.getMessage()); + SpawnOptions.builder().bodyMemoryBudget(512L << 20).build(); + SpawnOptions.builder().bodyMemoryBudget(256L << 20).maxBodySize(256L << 20).build(); + } + + @Test + void aCeilingAboveTheDefaultBudgetNeedsABudget() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> SpawnOptions.builder().maxBodySize(2_000_000_000L).build()); + assertEquals("maxBodySize (2000000000) is above the engine's default bodyMemoryBudget (1073741824), which " + + "the engine refuses; set bodyMemoryBudget too", e.getMessage()); + } + + @Test + void theLimitsNeedAnEngineThatHasTheFlags() { + IllegalArgumentException max = assertThrows(IllegalArgumentException.class, + () -> SpawnOptions.builder().version("0.21.0").maxBodySize(1024).build()); + assertEquals("maxBodySize needs a rift engine >= 0.22.0, but version is 0.21.0 " + + "(the older CLI has no --max-body-size flag)", max.getMessage()); + IllegalArgumentException journal = assertThrows(IllegalArgumentException.class, + () -> SpawnOptions.builder().version("0.21.0").journalMaxBytes(1024).build()); + assertEquals("journalMaxBytes needs a rift engine >= 0.22.0, but version is 0.21.0 " + + "(the older CLI has no --journal-max-bytes flag)", journal.getMessage()); + IllegalArgumentException budget = assertThrows(IllegalArgumentException.class, + () -> SpawnOptions.builder().version("0.21.0").bodyMemoryBudget(0).build()); + assertEquals("bodyMemoryBudget needs a rift engine >= 0.22.0, but version is 0.21.0 " + + "(the older CLI has no --body-memory-budget flag)", budget.getMessage()); + SpawnOptions.builder().version("0.22.0").maxBodySize(1024).build(); + } +} diff --git a/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedProvider.java b/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedProvider.java index 706bb6b..5c4c7c4 100644 --- a/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedProvider.java +++ b/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedProvider.java @@ -20,14 +20,22 @@ public boolean isAvailable() { return NativeLibraryResolver.resolvable(EmbeddedOptions.builder().build()); } + /** Whether the admin plane must be served before the first imposter exists. Package-private for tests. */ + static boolean needsAdminPlaneAtStart(EmbeddedOptions options) { + return options.serveAdminEagerly() || options.upstreamTrust().isPresent() || options.metricsPort().isPresent() + || options.maxBodySize().isPresent() || options.bodyMemoryBudget().isPresent() + || options.journalMaxBytes().isPresent(); + } + @Override public EmbeddedEngine start(EmbeddedOptions options) { NativeLibraryResolver.ResolvedLibrary resolved = NativeLibraryResolver.resolve(options); EmbeddedTransport transport = EmbeddedTransport.open(resolved.path(), options); - // Trust is applied when the admin plane is served, and an imposter keeps the outbound client - // it was created with — so the plane must be up before the first imposter can exist. The - // metrics listener comes up with the plane, so asking for one asks for the plane at startup. - if (options.serveAdminEagerly() || options.upstreamTrust().isPresent() || options.metricsPort().isPresent()) { + // Trust and the body/journal limits are applied when the admin plane is served, and an + // imposter keeps the outbound client and limits it was created with — so the plane must be up + // before the first imposter can exist. The metrics listener comes up with the plane, so asking + // for one asks for the plane at startup. + if (needsAdminPlaneAtStart(options)) { try { transport.adminUri(); } catch (RuntimeException e) { diff --git a/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java b/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java index 299544a..e1049a0 100644 --- a/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java +++ b/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java @@ -398,6 +398,9 @@ static JsonObject serveOptions(EmbeddedOptions options) { options.allowInjection().ifPresent(allow -> payload.put("allowInjection", JsonBool.of(allow))); options.requireAdminAuth().ifPresent(require -> payload.put("requireAdminAuth", JsonBool.of(require))); options.metricsPort().ifPresent(port -> payload.put("metricsPort", JsonNumber.of(port))); + options.maxBodySize().ifPresent(bytes -> payload.put("maxBodySize", JsonNumber.of(bytes))); + options.bodyMemoryBudget().ifPresent(bytes -> payload.put("bodyMemoryBudget", JsonNumber.of(bytes))); + options.journalMaxBytes().ifPresent(bytes -> payload.put("journalMaxBytes", JsonNumber.of(bytes))); options.upstreamTrust().ifPresent(trust -> payload.put(serveOptionKey(trust), serveOptionValue(trust))); return payload.build(); } @@ -435,6 +438,9 @@ static void requireAdvertised(EmbeddedOptions options, Supplier build options.allowInjection().ifPresent(v -> requested.add(new ServeOption("allowInjection", "0.17.0", "allowInjection"))); options.requireAdminAuth().ifPresent(v -> requested.add(new ServeOption("requireAdminAuth", "0.17.0", "requireAdminAuth"))); options.metricsPort().ifPresent(v -> requested.add(new ServeOption("metricsPort", "0.17.0", "metricsPort"))); + options.maxBodySize().ifPresent(v -> requested.add(new ServeOption("maxBodySize", "0.22.0", "maxBodySize"))); + options.bodyMemoryBudget().ifPresent(v -> requested.add(new ServeOption("bodyMemoryBudget", "0.22.0", "bodyMemoryBudget"))); + options.journalMaxBytes().ifPresent(v -> requested.add(new ServeOption("journalMaxBytes", "0.22.0", "journalMaxBytes"))); options.upstreamTrust().ifPresent(trust -> requested.add(new ServeOption(serveOptionKey(trust), "0.18.0", "upstreamTrust"))); if (requested.isEmpty()) { return; diff --git a/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/LaunchOptionsIT.java b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/LaunchOptionsIT.java index 284eb34..3150b28 100644 --- a/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/LaunchOptionsIT.java +++ b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/LaunchOptionsIT.java @@ -1,6 +1,8 @@ package io.github.achirdlabs.rift.embedded; import io.github.achirdlabs.rift.EmbeddedOptions; +import io.github.achirdlabs.rift.Imposter; +import io.github.achirdlabs.rift.RecordedRequest; import io.github.achirdlabs.rift.Rift; import io.github.achirdlabs.rift.error.RiftException; import io.github.achirdlabs.rift.model.ImposterDefinition; @@ -17,6 +19,9 @@ import java.time.Duration; import java.util.List; +import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter; +import static io.github.achirdlabs.rift.dsl.RiftDsl.ok; +import static io.github.achirdlabs.rift.dsl.RiftDsl.onRequest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -87,4 +92,33 @@ void aMetricsPortThatIsTakenFailsTheEmbeddedStartItself() throws Exception { assertTrue(e.getMessage().contains("metrics bind"), e.getMessage()); } } + @Test + void aBodyOverMaxBodySizeIsRefusedWith413OnAnImposterCreatedAfterStart() throws Exception { + // the engine applies the limit only to imposters created after it is set: the plane must be up first + try (Rift rift = Rift.embedded(options().maxBodySize(1024).build())) { + int port = rift.create(imposter("capped").stub(onRequest().willReturn(ok()))).port(); + HttpResponse response = post(port, 2048); + assertEquals(413, response.statusCode()); + assertEquals(200, post(port, 512).statusCode(), "a body within the limit is served"); + } + } + + @Test + void aBodyLargerThanTheJournalBudgetIsRecordedWithoutIt() throws Exception { + try (Rift rift = Rift.embedded(options().journalMaxBytes(4096).build())) { + Imposter imposter = rift.create(imposter("journal").record().stub(onRequest().willReturn(ok()))); + assertEquals(200, post(imposter.port(), 16_384).statusCode()); + RecordedRequest recorded = imposter.recorded().get(0); + assertTrue(recorded.bodyOmitted(), "recorded with its body (" + recorded.body().length() + " bytes)"); + assertEquals("", recorded.body()); + } + } + + private static HttpResponse post(int port, int bodyBytes) throws Exception { + // a fresh client each time: the engine closes the connection after a 413 + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + return client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/upload")) + .timeout(Duration.ofSeconds(5)).POST(HttpRequest.BodyPublishers.ofString("x".repeat(bodyBytes))).build(), + HttpResponse.BodyHandlers.discarding()); + } } diff --git a/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsBodyLimitsTest.java b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsBodyLimitsTest.java new file mode 100644 index 0000000..07c4458 --- /dev/null +++ b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsBodyLimitsTest.java @@ -0,0 +1,75 @@ +package io.github.achirdlabs.rift.embedded; + +import io.github.achirdlabs.rift.EmbeddedOptions; +import io.github.achirdlabs.rift.error.EngineUnavailable; +import io.github.achirdlabs.rift.json.JsonValue; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The body and journal limits in the {@code rift_serve_admin} payload, and their advertisement gate (#309). */ +class ServeOptionsBodyLimitsTest { + + private static final String V021_BUILD_INFO = "{\"version\": \"0.21.0\", \"serveOptions\": " + + "[\"host\", \"port\", \"apiKey\", \"allowInjection\", \"requireAdminAuth\", \"metricsPort\", " + + "\"upstreamCaFile\", \"upstreamCaPem\", \"upstreamTlsSkipVerify\"]}"; + private static final String V022_BUILD_INFO = "{\"version\": \"0.22.0\", \"serveOptions\": " + + "[\"host\", \"port\", \"maxBodySize\", \"bodyMemoryBudget\", \"journalMaxBytes\"]}"; + + @Test + void theLimitsAreSentUnderTheEnginesNamesAsPlainNumbers() { + String json = EmbeddedTransport.serveOptions(EmbeddedOptions.builder() + .maxBodySize(3_000_000_000L).bodyMemoryBudget(0).journalMaxBytes(4096).build()).toJson(); + assertEquals("{\"host\":\"127.0.0.1\",\"port\":0,\"maxBodySize\":3000000000,\"bodyMemoryBudget\":0," + + "\"journalMaxBytes\":4096}", json); + } + + @Test + void unsetLimitsAreAbsent() { + String json = EmbeddedTransport.serveOptions(EmbeddedOptions.builder().build()).toJson(); + assertFalse(json.contains("maxBodySize") || json.contains("bodyMemoryBudget") || json.contains("journalMaxBytes"), json); + } + + @Test + void anEngineThatDoesNotAdvertiseALimitIsRefusedNamingTheSetter() { + EngineUnavailable e = assertThrows(EngineUnavailable.class, () -> EmbeddedTransport.requireAdvertised( + EmbeddedOptions.builder().journalMaxBytes(4096).build(), () -> JsonValue.parse(V021_BUILD_INFO))); + // the advertised set is unordered, so only the fixed parts of the message are pinned + assertTrue(e.getMessage().startsWith("journalMaxBytes needs a rift engine >= 0.22.0; this engine advertises serveOptions ["), + e.getMessage()); + assertTrue(e.getMessage().endsWith("]. Upgrade the engine, or drop EmbeddedOptions.journalMaxBytes."), e.getMessage()); + } + + @Test + void eachLimitIsCheckedForItsOwnKey() { + for (EmbeddedOptions options : new EmbeddedOptions[] { + EmbeddedOptions.builder().maxBodySize(1024).build(), + EmbeddedOptions.builder().bodyMemoryBudget(0).build(), + EmbeddedOptions.builder().journalMaxBytes(4096).build()}) { + EngineUnavailable e = assertThrows(EngineUnavailable.class, + () -> EmbeddedTransport.requireAdvertised(options, () -> JsonValue.parse(V021_BUILD_INFO))); + String key = options.maxBodySize().isPresent() ? "maxBodySize" + : options.bodyMemoryBudget().isPresent() ? "bodyMemoryBudget" : "journalMaxBytes"; + assertTrue(e.getMessage().startsWith(key + " needs a rift engine >= 0.22.0"), e.getMessage()); + assertTrue(e.getMessage().endsWith("drop EmbeddedOptions." + key + "."), e.getMessage()); + } + } + + @Test + void theAdminPlaneStartsWithTheEngineWhenAnyLimitIsSet() { + // the engine applies the limits only to imposters created after rift_serve_admin + assertTrue(EmbeddedProvider.needsAdminPlaneAtStart(EmbeddedOptions.builder().maxBodySize(1024).build())); + assertTrue(EmbeddedProvider.needsAdminPlaneAtStart(EmbeddedOptions.builder().bodyMemoryBudget(0).build())); + assertTrue(EmbeddedProvider.needsAdminPlaneAtStart(EmbeddedOptions.builder().journalMaxBytes(4096).build())); + assertFalse(EmbeddedProvider.needsAdminPlaneAtStart(EmbeddedOptions.builder().build())); + } + + @Test + void anEngineThatAdvertisesTheLimitsAcceptsThem() { + EmbeddedTransport.requireAdvertised(EmbeddedOptions.builder() + .maxBodySize(1024).bodyMemoryBudget(0).journalMaxBytes(4096).build(), () -> JsonValue.parse(V022_BUILD_INFO)); + } +} diff --git a/rift-java-testcontainers/src/main/java/io/github/achirdlabs/rift/testcontainers/RiftContainer.java b/rift-java-testcontainers/src/main/java/io/github/achirdlabs/rift/testcontainers/RiftContainer.java index ad04e9b..5cb6ffe 100644 --- a/rift-java-testcontainers/src/main/java/io/github/achirdlabs/rift/testcontainers/RiftContainer.java +++ b/rift-java-testcontainers/src/main/java/io/github/achirdlabs/rift/testcontainers/RiftContainer.java @@ -23,7 +23,9 @@ import java.nio.file.Path; import java.util.Objects; import java.util.Optional; +import java.util.OptionalLong; import java.util.function.Supplier; +import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -67,6 +69,11 @@ public final class RiftContainer extends GenericContainer { private static final String INTERCEPT_CA_KEY_ENV = "RIFT_INTERCEPT_CA_KEY"; /** An image tag that declares an engine version ({@code v0.18.0}, {@code 0.18.0-static}), as opposed to {@code latest}. */ private static final Pattern VERSION_TAG = Pattern.compile("[vV]?\\d+(\\.\\d+){0,2}([-+].*)?"); + private static final Pattern TAG_MAJOR_MINOR = Pattern.compile("[vV]?(\\d+)(?:\\.(\\d+))?.*"); + /** The engine's {@code maxBodySize} when none is set: 512 MiB. */ + private static final long DEFAULT_MAX_BODY_SIZE = 512L << 20; + /** The engine's {@code bodyMemoryBudget} when none is set: 1 GiB. */ + private static final long DEFAULT_BODY_MEMORY_BUDGET = 1L << 30; private final DockerImageName imageName; private Optional apiKey = Optional.empty(); @@ -77,6 +84,8 @@ public final class RiftContainer extends GenericContainer { /** The committed CA as read and copied in at the start; what an attach hands back. */ private Optional launchedCa = Optional.empty(); private Optional upstreamTrust = Optional.empty(); + private OptionalLong maxBodySize = OptionalLong.empty(); + private OptionalLong bodyMemoryBudget = OptionalLong.empty(); /** Uses {@code zainalpour/rift-proxy:v}{@link #ENGINE_VERSION}. */ public RiftContainer() { @@ -298,6 +307,102 @@ public RiftContainer withUpstreamTrust(UpstreamTrust upstreamTrust) { return self(); } + /** + * The largest request, proxied upstream response or admin body the engine accepts, in bytes + * ({@code RIFT_MAX_BODY_SIZE}); a larger one gets {@code 413} and the connection is closed. Unset, + * the engine's own 512 MiB applies; a value above 1 GiB needs {@link #withBodyMemoryBudget(long)} + * too, checked at start. + * + *

Requires a rift engine ≥ 0.22.0, checked against the image tag as {@link + * #withUpstreamTrust(UpstreamTrust)} is; an older engine behind a tag that is not a version + * ignores the setting. + * + * @throws IllegalArgumentException if {@code bytes} is below 1, or the image tag is a version + * older than 0.22.0 + */ + public RiftContainer withMaxBodySize(long bytes) { + if (bytes < 1) { + throw new IllegalArgumentException("maxBodySize must be at least 1 byte, was " + bytes); + } + requireBodyLimitsTag("maxBodySize", "RIFT_MAX_BODY_SIZE"); + this.maxBodySize = OptionalLong.of(bytes); + withEnv("RIFT_MAX_BODY_SIZE", Long.toString(bytes)); + return self(); + } + + /** + * The memory every body buffered at once may share, in bytes ({@code RIFT_BODY_MEMORY_BUDGET}); + * past it a request gets {@code 503} with {@code Retry-After: 1}. {@code 0} is no budget. A + * non-zero budget must be at least {@link #withMaxBodySize(long)}, checked at start. Requires a + * rift engine ≥ 0.22.0, checked as for {@code withMaxBodySize}. + * + * @throws IllegalArgumentException if {@code bytes} is negative, or the image tag is a version + * older than 0.22.0 + */ + public RiftContainer withBodyMemoryBudget(long bytes) { + if (bytes < 0) { + throw new IllegalArgumentException("bodyMemoryBudget must be 0 (unbounded) or more bytes, was " + bytes); + } + requireBodyLimitsTag("bodyMemoryBudget", "RIFT_BODY_MEMORY_BUDGET"); + this.bodyMemoryBudget = OptionalLong.of(bytes); + withEnv("RIFT_BODY_MEMORY_BUDGET", Long.toString(bytes)); + return self(); + } + + /** + * How many bytes of recorded requests each imposter keeps ({@code RIFT_JOURNAL_MAX_BYTES}), + * oldest evicted first. A request larger than the whole budget is recorded without its body: + * see {@code RecordedRequest.bodyOmitted()}. Requires a rift engine ≥ 0.22.0, checked as for + * {@link #withMaxBodySize(long)}. + * + * @throws IllegalArgumentException if {@code bytes} is below 1, or the image tag is a version + * older than 0.22.0 + */ + public RiftContainer withJournalMaxBytes(long bytes) { + if (bytes < 1) { + throw new IllegalArgumentException("journalMaxBytes must be at least 1 byte, was " + bytes); + } + requireBodyLimitsTag("journalMaxBytes", "RIFT_JOURNAL_MAX_BYTES"); + withEnv("RIFT_JOURNAL_MAX_BYTES", Long.toString(bytes)); + return self(); + } + + private void requireBodyLimitsTag(String option, String env) { + String tag = imageName.getVersionPart(); + Matcher version = TAG_MAJOR_MINOR.matcher(tag); + if (VERSION_TAG.matcher(tag).matches() && version.matches()) { + long major = Long.parseLong(version.group(1)); + long minor = version.group(2) == null ? 0 : Long.parseLong(version.group(2)); + if (major == 0 && minor < 22) { + throw new IllegalArgumentException(option + " needs a rift engine >= 0.22.0, but the image tag is " + + tag + " (the older engine ignores " + env + ")"); + } + } + } + + /** + * The engine refuses a non-zero budget below the body ceiling, and fills in whichever of the two + * is unset with its own default (512 MiB ceiling, 1 GiB budget) before comparing them. The same + * rule as {@code SpawnOptions}/{@code EmbeddedOptions} apply; package-private for tests. + */ + void requireBudgetCoversCeiling() { + if (bodyMemoryBudget.isPresent()) { + long budget = bodyMemoryBudget.getAsLong(); + long ceiling = maxBodySize.orElse(DEFAULT_MAX_BODY_SIZE); + if (budget != 0 && budget < ceiling) { + throw new IllegalArgumentException(maxBodySize.isPresent() + ? "bodyMemoryBudget (" + budget + ") is below maxBodySize (" + ceiling + + "), which the engine refuses; raise it, or set 0 for no budget" + : "bodyMemoryBudget (" + budget + ") is below the engine's default maxBodySize (" + ceiling + + "), which the engine refuses; raise it, lower maxBodySize, or set 0 for no budget"); + } + } else if (maxBodySize.isPresent() && maxBodySize.getAsLong() > DEFAULT_BODY_MEMORY_BUDGET) { + throw new IllegalArgumentException("maxBodySize (" + maxBodySize.getAsLong() + ") is above the engine's " + + "default bodyMemoryBudget (" + DEFAULT_BODY_MEMORY_BUDGET + "), which the engine refuses; " + + "set bodyMemoryBudget too"); + } + } + /** * Applies {@link #withUpstreamTrust(UpstreamTrust)} as the container starts, so a replaced policy * leaves nothing behind and a {@link UpstreamTrust.CaFile} is read when the engine starts, as on @@ -306,6 +411,7 @@ public RiftContainer withUpstreamTrust(UpstreamTrust upstreamTrust) { @Override protected void configure() { super.configure(); + requireBudgetCoversCeiling(); skipVerifyWarning().ifPresent(warning -> LOG.log(Level.WARNING, warning)); upstreamCaCopy().ifPresent(pem -> { withCopyToContainer(pem, UPSTREAM_CA_PATH); diff --git a/rift-java-testcontainers/src/test/java/io/github/achirdlabs/rift/testcontainers/RiftContainerBodyLimitsTest.java b/rift-java-testcontainers/src/test/java/io/github/achirdlabs/rift/testcontainers/RiftContainerBodyLimitsTest.java new file mode 100644 index 0000000..b341286 --- /dev/null +++ b/rift-java-testcontainers/src/test/java/io/github/achirdlabs/rift/testcontainers/RiftContainerBodyLimitsTest.java @@ -0,0 +1,100 @@ +package io.github.achirdlabs.rift.testcontainers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** The body and journal limits reach a rift 0.22.0 container through the engine's env vars (#309). */ +class RiftContainerBodyLimitsTest { + + @Test + void theLimitsAreSetAsTheEnginesEnvVars() { + try (RiftContainer container = new RiftContainer() + .withMaxBodySize(3_000_000_000L).withBodyMemoryBudget(0).withJournalMaxBytes(4096)) { + assertEquals("3000000000", container.getEnvMap().get("RIFT_MAX_BODY_SIZE")); + assertEquals("0", container.getEnvMap().get("RIFT_BODY_MEMORY_BUDGET")); + assertEquals("4096", container.getEnvMap().get("RIFT_JOURNAL_MAX_BYTES")); + } + } + + @Test + void unsetLimitsSetNothing() { + try (RiftContainer container = new RiftContainer()) { + assertNull(container.getEnvMap().get("RIFT_MAX_BODY_SIZE")); + assertNull(container.getEnvMap().get("RIFT_BODY_MEMORY_BUDGET")); + assertNull(container.getEnvMap().get("RIFT_JOURNAL_MAX_BYTES")); + } + } + + @Test + void outOfRangeValuesAreRefused() { + try (RiftContainer container = new RiftContainer()) { + assertEquals("maxBodySize must be at least 1 byte, was 0", + assertThrows(IllegalArgumentException.class, () -> container.withMaxBodySize(0)).getMessage()); + assertEquals("bodyMemoryBudget must be 0 (unbounded) or more bytes, was -1", + assertThrows(IllegalArgumentException.class, () -> container.withBodyMemoryBudget(-1)).getMessage()); + assertEquals("journalMaxBytes must be at least 1 byte, was 0", + assertThrows(IllegalArgumentException.class, () -> container.withJournalMaxBytes(0)).getMessage()); + } + } + + @Test + void anImageTagOlderThan022IsRefusedByEverySetter() { + try (RiftContainer container = new RiftContainer(DockerImageName.parse("zainalpour/rift-proxy:v0.21.0"))) { + assertEquals("maxBodySize needs a rift engine >= 0.22.0, but the image tag is v0.21.0 " + + "(the older engine ignores RIFT_MAX_BODY_SIZE)", + assertThrows(IllegalArgumentException.class, () -> container.withMaxBodySize(1024)).getMessage()); + assertEquals("bodyMemoryBudget needs a rift engine >= 0.22.0, but the image tag is v0.21.0 " + + "(the older engine ignores RIFT_BODY_MEMORY_BUDGET)", + assertThrows(IllegalArgumentException.class, () -> container.withBodyMemoryBudget(0)).getMessage()); + assertEquals("journalMaxBytes needs a rift engine >= 0.22.0, but the image tag is v0.21.0 " + + "(the older engine ignores RIFT_JOURNAL_MAX_BYTES)", + assertThrows(IllegalArgumentException.class, () -> container.withJournalMaxBytes(1)).getMessage()); + } + for (String old : new String[] {"0.21", "v0.9.3", "0.21.9-static"}) { + try (RiftContainer container = new RiftContainer(DockerImageName.parse("zainalpour/rift-proxy:" + old))) { + assertThrows(IllegalArgumentException.class, () -> container.withJournalMaxBytes(1), old); + } + } + } + + @Test + void a022OrNewerTagAndAnUnversionedOneAreAccepted() { + for (String tag : new String[] {"v0.22.0", "0.22", "0.22.0-rc1", "v0.100.0", "1.0.0", "latest"}) { + try (RiftContainer container = new RiftContainer(DockerImageName.parse("zainalpour/rift-proxy:" + tag)) + .withJournalMaxBytes(4096)) { + assertEquals("4096", container.getEnvMap().get("RIFT_JOURNAL_MAX_BYTES"), tag); + } + } + } + + @Test + void theBudgetIsCheckedAgainstTheCeilingWhenTheContainerStarts() { + try (RiftContainer below = new RiftContainer().withMaxBodySize(2048).withBodyMemoryBudget(1024)) { + assertEquals("bodyMemoryBudget (1024) is below maxBodySize (2048), which the engine refuses; raise it, or set 0 " + + "for no budget", assertThrows(IllegalArgumentException.class, below::requireBudgetCoversCeiling).getMessage()); + } + try (RiftContainer alone = new RiftContainer().withBodyMemoryBudget(1024)) { + assertEquals("bodyMemoryBudget (1024) is below the engine's default maxBodySize (536870912), which the engine " + + "refuses; raise it, lower maxBodySize, or set 0 for no budget", + assertThrows(IllegalArgumentException.class, alone::requireBudgetCoversCeiling).getMessage()); + } + try (RiftContainer huge = new RiftContainer().withMaxBodySize((1L << 30) + 1)) { + assertEquals("maxBodySize (1073741825) is above the engine's default bodyMemoryBudget (1073741824), which the " + + "engine refuses; set bodyMemoryBudget too", + assertThrows(IllegalArgumentException.class, huge::requireBudgetCoversCeiling).getMessage()); + } + try (RiftContainer ok = new RiftContainer().withMaxBodySize(1L << 30)) { + ok.requireBudgetCoversCeiling(); + } + try (RiftContainer unbounded = new RiftContainer().withMaxBodySize(5_000_000_000L).withBodyMemoryBudget(0)) { + unbounded.requireBudgetCoversCeiling(); + } + try (RiftContainer equal = new RiftContainer().withMaxBodySize(2048).withBodyMemoryBudget(2048)) { + equal.requireBudgetCoversCeiling(); + } + } +}