From 97f2910a2310295afc3a4319e78ebdc32f32d50c Mon Sep 17 00:00:00 2001 From: Mohsen Zainalpour Date: Tue, 27 Oct 2026 10:55:41 -0400 Subject: [PATCH] feat(embedded): pass scriptsDir to the admin plane, which rift 0.24.0 confines to it rift 0.24.0's rift_serve_admin takes scriptsDir, and an imposter posted through that plane reads lookup CSVs, require() modules and file: scripts only under it; without it such a lookup is refused (rift#1611, rift#1617). The embedded transport routes replaceAll through the plane, so an embedded replaceAll carrying a lookup could not work at all on the pinned engine. EmbeddedOptions.scriptsDir(Path) sends it, refused like the other serve options unless the engine advertises it. It does not start the plane early: the engine reads the root per admin request, and host-call imposters (create, addStub) stay unconfined. Closes #343 --- README.md | 7 ++ docs/design/sdk-api.md | 5 ++ .../achirdlabs/rift/EmbeddedOptions.java | 29 +++++++- .../rift/embedded/EmbeddedTransport.java | 2 + .../rift/embedded/LaunchOptionsIT.java | 49 ++++++++++++++ .../embedded/ServeOptionsScriptsDirTest.java | 67 +++++++++++++++++++ 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/ServeOptionsScriptsDirTest.java 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)); + } +}