Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/design/sdk-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Path>; 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public final class EmbeddedOptions {
private final OptionalLong maxBodySize;
private final OptionalLong bodyMemoryBudget;
private final OptionalLong journalMaxBytes;
private final Optional<Path> scriptsDir;

private EmbeddedOptions(
Optional<Path> libraryPath,
Expand All @@ -45,7 +46,8 @@ private EmbeddedOptions(
OptionalInt metricsPort,
OptionalLong maxBodySize,
OptionalLong bodyMemoryBudget,
OptionalLong journalMaxBytes) {
OptionalLong journalMaxBytes,
Optional<Path> scriptsDir) {
this.libraryPath = libraryPath;
this.versionCheck = versionCheck;
this.serveAdminEagerly = serveAdminEagerly;
Expand All @@ -59,6 +61,7 @@ private EmbeddedOptions(
this.maxBodySize = maxBodySize;
this.bodyMemoryBudget = bodyMemoryBudget;
this.journalMaxBytes = journalMaxBytes;
this.scriptsDir = scriptsDir;
}

public static Builder builder() {
Expand Down Expand Up @@ -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<Path> scriptsDir() {
return scriptsDir;
}

public static final class Builder {

private Optional<Path> libraryPath = Optional.empty();
Expand All @@ -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<Path> scriptsDir = Optional.empty();

private Builder() {
}
Expand Down Expand Up @@ -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 &ge; 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
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -441,6 +442,7 @@ static void requireAdvertised(EmbeddedOptions options, Supplier<JsonValue> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
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;
import java.net.URI;
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;
Expand Down Expand Up @@ -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<String> 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<Void> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading