diff --git a/README.md b/README.md index be153db..0845426 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,13 @@ larger body gets `413`), `bodyMemoryBudget` (memory shared by buffered bodies; ` `journalMaxBytes` (recorded-request bytes per imposter; a larger request is recorded without its body, `RecordedRequest.bodyOmitted()`), all in bytes. +From rift 0.24.0 an imposter written through the admin API reads `lookup` CSV files and `require()` +modules only under a scripts directory, and refuses such a `lookup` without one. Set it with +`SpawnOptions.scriptsDir(dir)` (`--scripts-dir`) or `EmbeddedOptions.scriptsDir(dir)`; a relative +`lookup` path in an imposter posted through the admin API resolves against it. Embedded `replaceAll` +goes through the admin plane, so it needs `scriptsDir` for a lookup; embedded `create` and `addStub` +do not, and resolve a relative path against the JVM's working directory. + ### JUnit 5 ```java diff --git a/docs/design/sdk-api.md b/docs/design/sdk-api.md index 02741c4..edbcb65 100644 --- a/docs/design/sdk-api.md +++ b/docs/design/sdk-api.md @@ -224,6 +224,11 @@ public final class EmbeddedOptions { // 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). + // scriptsDir (Optional; rift_serve_admin's scriptsDir, advertised from rift 0.24.0): the only + // root an imposter posted through the plane may read lookup CSVs, require() modules and file: + // scripts from (rift#1617). Unset, 0.24.0 refuses such a lookup on the plane, replaceAll + // included. Not eager: the plane takes it whenever it is served. SpawnOptions.scriptsDir is the + // same root as --scripts-dir. } public sealed interface UpstreamTrust { // outbound TLS trust for proxying/recording (#209) 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 c7b6cc6..da081bc 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 @@ -31,6 +31,7 @@ public final class EmbeddedOptions { private final OptionalLong maxBodySize; private final OptionalLong bodyMemoryBudget; private final OptionalLong journalMaxBytes; + private final Optional scriptsDir; private EmbeddedOptions( Optional libraryPath, @@ -45,7 +46,8 @@ private EmbeddedOptions( OptionalInt metricsPort, OptionalLong maxBodySize, OptionalLong bodyMemoryBudget, - OptionalLong journalMaxBytes) { + OptionalLong journalMaxBytes, + Optional scriptsDir) { this.libraryPath = libraryPath; this.versionCheck = versionCheck; this.serveAdminEagerly = serveAdminEagerly; @@ -59,6 +61,7 @@ private EmbeddedOptions( this.maxBodySize = maxBodySize; this.bodyMemoryBudget = bodyMemoryBudget; this.journalMaxBytes = journalMaxBytes; + this.scriptsDir = scriptsDir; } public static Builder builder() { @@ -126,6 +129,11 @@ public OptionalLong journalMaxBytes() { return journalMaxBytes; } + /** The admin plane's lookup CSV and module root, if set; see {@link Builder#scriptsDir(Path)}. */ + public Optional scriptsDir() { + return scriptsDir; + } + public static final class Builder { private Optional libraryPath = Optional.empty(); @@ -141,6 +149,7 @@ public static final class Builder { private OptionalLong maxBodySize = OptionalLong.empty(); private OptionalLong bodyMemoryBudget = OptionalLong.empty(); private OptionalLong journalMaxBytes = OptionalLong.empty(); + private Optional scriptsDir = Optional.empty(); private Builder() { } @@ -348,6 +357,22 @@ public Builder journalMaxBytes(long bytes) { return this; } + /** + * The admin plane's {@code scriptsDir}: the only directory an imposter posted through it may + * read {@code lookup} CSV files, {@code require()} modules and {@code file:} scripts from, a + * relative {@code lookup} path resolving against it. Unset, from rift 0.24.0 (rift#1611, + * rift#1617) the plane reads none of them and refuses such a {@code lookup} — which includes + * {@link Rift#replaceAll}, since this transport routes it through the plane. Imposters the + * SDK creates directly ({@code create}, {@code addStub}) are not confined. The engine runs in + * this JVM, so a relative {@code scriptsDir} resolves against its working directory. Needs an engine that + * advertises {@code scriptsDir} in {@code serveOptions} (rift ≥ 0.24.0), checked when the + * plane is served. + */ + public Builder scriptsDir(Path scriptsDir) { + this.scriptsDir = Optional.of(Objects.requireNonNull(scriptsDir, "scriptsDir")); + return this; + } + /** * @throws IllegalArgumentException if a non-zero {@link #bodyMemoryBudget(long)} is below * {@link #maxBodySize(long)}, or a ceiling above 1 GiB has no @@ -357,7 +382,7 @@ public EmbeddedOptions build() { BodyLimits.requireBudgetCoversCeiling(maxBodySize, bodyMemoryBudget); return new EmbeddedOptions(libraryPath, versionCheck, serveAdminEagerly, adminHost, adminPort, apiKey, upstreamTrust, allowInjection, requireAdminAuth, metricsPort, maxBodySize, bodyMemoryBudget, - journalMaxBytes); + journalMaxBytes, scriptsDir); } } } 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 e1049a0..2622b98 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 @@ -401,6 +401,7 @@ static JsonObject serveOptions(EmbeddedOptions options) { 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.scriptsDir().ifPresent(dir -> payload.put("scriptsDir", new JsonString(dir.toString()))); options.upstreamTrust().ifPresent(trust -> payload.put(serveOptionKey(trust), serveOptionValue(trust))); return payload.build(); } @@ -441,6 +442,7 @@ static void requireAdvertised(EmbeddedOptions options, Supplier build 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.scriptsDir().ifPresent(v -> requested.add(new ServeOption("scriptsDir", "0.24.0", "scriptsDir"))); 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 3150b28..947dc35 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 @@ -8,6 +8,7 @@ import io.github.achirdlabs.rift.model.ImposterDefinition; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.IOException; import java.net.ServerSocket; @@ -15,6 +16,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.List; @@ -114,6 +116,53 @@ void aBodyLargerThanTheJournalBudgetIsRecordedWithoutIt() throws Exception { } } + /** + * {@code replaceAll} goes through the admin plane, which from rift 0.24.0 reads a lookup CSV only + * under {@code scriptsDir}, a relative path from there (rift#1617, #343). + */ + @Test + void scriptsDirLetsTheAdminPlaneReadALookupCsvUnderIt(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("rows.csv"), "id,name\n7,seven\n"); + int port = freePort(); + try (Rift rift = Rift.embedded(options().scriptsDir(dir).build())) { + rift.replaceAll(List.of(lookupImposter(port, "rows.csv"))); + HttpResponse answer = HTTP.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/7")) + .timeout(Duration.ofSeconds(5)).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals("seven", answer.body(), "the relative path resolved against scriptsDir"); + } + } + + @Test + void withoutScriptsDirTheAdminPlaneRefusesALookup() throws Exception { + try (Rift rift = Rift.embedded(options().build())) { + RiftException e = assertThrows(RiftException.class, + () -> rift.replaceAll(List.of(lookupImposter(freePort(), "rows.csv")))); + assertTrue(e.getMessage().contains("no --scripts-dir is configured"), e.getMessage()); + } + } + + @Test + void aLookupCsvOutsideScriptsDirIsRefused(@TempDir Path root) throws Exception { + Path dir = Files.createDirectory(root.resolve("scripts")); + Path outside = Files.writeString(root.resolve("outside.csv"), "id,name\n7,seven\n"); + try (Rift rift = Rift.embedded(options().scriptsDir(dir).build())) { + RiftException e = assertThrows(RiftException.class, + () -> rift.replaceAll(List.of(lookupImposter(freePort(), outside.toString())))); + assertTrue(e.getMessage().contains("outside --scripts-dir"), e.getMessage()); + } + } + + private static ImposterDefinition lookupImposter(int port, String csvPath) { + return ImposterDefinition.fromJson(""" + {"port": %d, "protocol": "http", "stubs": [{"responses": [{ + "is": {"body": "${row}[name]"}, + "_behaviors": {"lookup": [{ + "key": {"from": "path", "using": {"method": "regex", "selector": "/(\\\\d+)"}, "index": 1}, + "fromDataSource": {"csv": {"path": "%s", "keyColumn": "id"}}, + "into": "${row}"}]}}]}]} + """.formatted(port, csvPath.replace("\\", "\\\\"))); + } + 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(); diff --git a/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsScriptsDirTest.java b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsScriptsDirTest.java new file mode 100644 index 0000000..09c12ea --- /dev/null +++ b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsScriptsDirTest.java @@ -0,0 +1,67 @@ +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.JsonObject; +import io.github.achirdlabs.rift.json.JsonString; +import io.github.achirdlabs.rift.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** {@code scriptsDir} in the {@code rift_serve_admin} payload, and its advertisement gate (#343, rift#1617). */ +class ServeOptionsScriptsDirTest { + + private static final String V023_BUILD_INFO = "{\"version\": \"0.23.0\", \"serveOptions\": " + + "[\"host\", \"port\", \"allowInjection\", \"maxBodySize\"]}"; + private static final String V024_BUILD_INFO = "{\"version\": \"0.24.0\", \"serveOptions\": " + + "[\"host\", \"port\", \"allowInjection\", \"scriptsDir\"]}"; + + @Test + void scriptsDirIsSentUnderTheEnginesName() { + Path dir = Path.of("/srv/rift-scripts"); + JsonObject payload = EmbeddedTransport.serveOptions(EmbeddedOptions.builder().scriptsDir(dir).build()); + assertEquals(new JsonString(dir.toString()), payload.get("scriptsDir")); + assertEquals(3, payload.fields().size(), "host, port and scriptsDir only: " + payload.toJson()); + } + + @Test + void anUnsetScriptsDirIsAbsent() { + EmbeddedOptions options = EmbeddedOptions.builder().build(); + assertTrue(options.scriptsDir().isEmpty()); + assertNull(EmbeddedTransport.serveOptions(options).get("scriptsDir")); + } + + @Test + void anEngineThatDoesNotAdvertiseScriptsDirIsRefusedNamingTheSetter() { + EngineUnavailable e = assertThrows(EngineUnavailable.class, () -> EmbeddedTransport.requireAdvertised( + EmbeddedOptions.builder().scriptsDir(Path.of("/srv/rift-scripts")).build(), + () -> JsonValue.parse(V023_BUILD_INFO))); + assertTrue(e.getMessage().startsWith("scriptsDir needs a rift engine >= 0.24.0; this engine advertises serveOptions ["), + e.getMessage()); + assertTrue(e.getMessage().endsWith("]. Upgrade the engine, or drop EmbeddedOptions.scriptsDir."), e.getMessage()); + } + + @Test + void anEngineThatAdvertisesScriptsDirAcceptsIt() { + EmbeddedTransport.requireAdvertised(EmbeddedOptions.builder().scriptsDir(Path.of("/srv/rift-scripts")).build(), + () -> JsonValue.parse(V024_BUILD_INFO)); + } + + @Test + void scriptsDirDoesNotStartTheAdminPlaneEagerly() { + // it governs only imposters posted to the plane, and the plane takes it whenever it is served + assertFalse(EmbeddedProvider.needsAdminPlaneAtStart(EmbeddedOptions.builder().scriptsDir(Path.of("/srv")).build())); + } + + @Test + void aNullScriptsDirIsRefused() { + assertThrows(NullPointerException.class, () -> EmbeddedOptions.builder().scriptsDir(null)); + } +}