diff --git a/pom.xml b/pom.xml
index ff7783e..0baa0f6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,6 +72,8 @@
3.2.1
3.2.7
1.3.0
+ 2.1.3
+ 1.1.7
@@ -102,7 +104,18 @@
${jimfs.version}
test
-
+
+ jakarta.json
+ jakarta.json-api
+ ${jakarta.json-api.version}
+ true
+
+
+ org.eclipse.parsson
+ jakarta.json
+ ${jakarta.json.version}
+ test
+
diff --git a/src/main/java/org/extism/chicory/sdk/ChicoryModule.java b/src/main/java/org/extism/chicory/sdk/ChicoryModule.java
index 2f2f1ff..b1a7e1b 100644
--- a/src/main/java/org/extism/chicory/sdk/ChicoryModule.java
+++ b/src/main/java/org/extism/chicory/sdk/ChicoryModule.java
@@ -1,6 +1,5 @@
package org.extism.chicory.sdk;
-import com.dylibso.chicory.experimental.aot.AotMachine;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;
import com.dylibso.chicory.wasm.WasmModule;
diff --git a/src/main/java/org/extism/chicory/sdk/HostEnv.java b/src/main/java/org/extism/chicory/sdk/HostEnv.java
index 5e65a98..34c02dd 100644
--- a/src/main/java/org/extism/chicory/sdk/HostEnv.java
+++ b/src/main/java/org/extism/chicory/sdk/HostEnv.java
@@ -3,17 +3,23 @@
import com.dylibso.chicory.log.Logger;
import com.dylibso.chicory.runtime.HostFunction;
import com.dylibso.chicory.runtime.Instance;
-import com.dylibso.chicory.wasm.types.Value;
import com.dylibso.chicory.wasm.types.ValueType;
-
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
-import static com.dylibso.chicory.wasm.types.Value.i64;
-
public class HostEnv {
private final Kernel kernel;
@@ -22,14 +28,16 @@ public class HostEnv {
private final Log log;
private final Var var;
private final Config config;
+ private final Http http;
- public HostEnv(Kernel kernel, Map config, Logger logger) {
+ public HostEnv(Kernel kernel, Map config, String[] allowedHosts, Logger logger) {
this.kernel = kernel;
this.memory = new Memory();
this.logger = logger;
this.config = new Config(config);
this.var = new Var();
this.log = new Log();
+ this.http = new Http(allowedHosts);
}
public Log log() {
@@ -44,12 +52,17 @@ public Config config() {
return config;
}
+ public Http http() {
+ return http;
+ }
+
public HostFunction[] toHostFunctions() {
return concat(
kernel.toHostFunctions(),
log.toHostFunctions(),
var.toHostFunctions(),
- config.toHostFunctions());
+ config.toHostFunctions(),
+ http.toHostFunctions());
}
private HostFunction[] concat(HostFunction[]... hfs) {
@@ -108,7 +121,8 @@ long writeString(String s) {
}
public class Log {
- private Log(){}
+ private Log() {
+ }
public void log(LogLevel level, String message) {
logger.log(level.toChicoryLogLevel(), message, null);
@@ -156,9 +170,10 @@ HostFunction[] toHostFunctions() {
}
public class Var {
- private final Map vars = new ConcurrentHashMap<>();
+ private final Map vars = new ConcurrentHashMap<>();
- private Var() {}
+ private Var() {
+ }
public byte[] get(String key) {
return vars.get(key);
@@ -246,7 +261,172 @@ HostFunction[] toHostFunctions() {
}
+ public class Http {
+ private final HostPattern[] hostPatterns;
+ HttpClient httpClient;
+ HttpResponse lastResponse;
+
+ public Http(String[] allowedHosts) {
+ this.hostPatterns = new HostPattern[allowedHosts.length];
+ for (int i = 0; i < allowedHosts.length; i++) {
+ this.hostPatterns[i] = new HostPattern(allowedHosts[i]);
+ }
+ }
+
+ public HttpClient httpClient() {
+ if (httpClient == null) {
+ httpClient = HttpClient.newHttpClient();
+ }
+ return httpClient;
+ }
+
+ long[] request(Instance instance, long... args) {
+ var result = new long[1];
+
+ var requestOffset = args[0];
+ var bodyOffset = args[1];
+
+ var requestJson = memory().readBytes(requestOffset);
+ kernel.free.apply(requestOffset);
+
+ byte[] requestBody;
+ if (bodyOffset == 0) {
+ requestBody = new byte[0];
+ } else {
+ requestBody = memory().readBytes(bodyOffset);
+ kernel.free.apply(bodyOffset);
+ }
+
+ var request = Json.createReader(new ByteArrayInputStream(requestJson))
+ .readObject();
+
+ var method = request.getJsonString("method").getString();
+ var uri = URI.create(request.getJsonString("url").getString());
+ var headers = request.getJsonObject("headers");
+
+ Map headersMap = new HashMap<>();
+ for (var key : headers.keySet()) {
+ headersMap.put(key, headers.getString(key));
+ }
+
+ byte[] body = request(method, uri, headersMap, requestBody);
+ if (body.length == 0) {
+ result[0] = 0;
+ } else {
+ result[0] = memory().writeBytes(body);
+ }
+
+ return result;
+ }
+
+ byte[] request(String method, URI uri, Map headers, byte[] requestBody) {
+ HttpRequest.BodyPublisher bodyPublisher;
+ if (requestBody.length == 0) {
+ bodyPublisher = HttpRequest.BodyPublishers.noBody();
+ } else {
+ bodyPublisher = HttpRequest.BodyPublishers.ofByteArray(requestBody);
+ }
+
+ var host = uri.getHost();
+ if (Arrays.stream(hostPatterns).anyMatch(p -> !p.matches(host))) {
+ throw new ExtismException(String.format("HTTP request to '%s' is not allowed", host));
+ }
+
+ var reqBuilder = HttpRequest.newBuilder().uri(uri);
+ for (var key : headers.keySet()) {
+ reqBuilder.header(key, headers.get(key));
+ }
+
+ var req = reqBuilder.method(method, bodyPublisher).build();
+
+ try {
+ this.lastResponse =
+ httpClient().send(req, HttpResponse.BodyHandlers.ofByteArray());
+ return lastResponse.body();
+ } catch (IOException | InterruptedException e) {
+ // FIXME gracefully handle the interruption
+ throw new ExtismException(e);
+ }
+ }
+
+ long[] statusCode(Instance instance, long... args) {
+ return new long[]{lastResponse == null ? 0 : lastResponse.statusCode()};
+ }
+
+
+ long[] headers(Instance instance, long[] longs) {
+ var result = new long[1];
+ if (lastResponse == null) {
+ return result;
+ }
+
+ // FIXME duplicated headers are effectively overwriting duplicate values!
+ var objBuilder = Json.createObjectBuilder();
+ for (var entry : lastResponse.headers().map().entrySet()) {
+ for (var v : entry.getValue()) {
+ objBuilder.add(entry.getKey(), v);
+ }
+ }
+
+ var bytes = objBuilder.build().toString().getBytes(StandardCharsets.UTF_8);
+ result[0] = memory().writeBytes(bytes);
+ return result;
+ }
+
+
+ public HostFunction[] toHostFunctions() {
+ return new HostFunction[]{
+ new HostFunction(
+ Kernel.IMPORT_MODULE_NAME,
+ "http_request",
+ List.of(ValueType.I64, ValueType.I64),
+ List.of(ValueType.I64),
+ this::request),
+ new HostFunction(
+ Kernel.IMPORT_MODULE_NAME,
+ "http_status_code",
+ List.of(),
+ List.of(ValueType.I32),
+ this::statusCode),
+ new HostFunction(
+ Kernel.IMPORT_MODULE_NAME,
+ "http_headers",
+ List.of(),
+ List.of(ValueType.I64),
+ this::headers),
+
+ };
+ }
+ }
+
+ private static class HostPattern {
+ private final String pattern;
+ private final boolean exact;
+
+ public HostPattern(String pattern) {
+ if (pattern.indexOf('*', 1) != -1) {
+ throw new ExtismException("Illegal pattern " + pattern);
+ }
+ int wildcard = pattern.indexOf('*');
+ if (wildcard < 0) {
+ this.exact = true;
+ this.pattern = pattern;
+ } else if (wildcard == 0) {
+ this.exact = false;
+ this.pattern = pattern.substring(1);
+ } else {
+ throw new ExtismException("Illegal pattern " + pattern);
+ }
+ }
+ public boolean matches(String host) {
+ if (exact) {
+ return host.equals(pattern);
+ } else {
+ return host.endsWith(pattern);
+ }
+ }
+ }
}
diff --git a/src/main/java/org/extism/chicory/sdk/Linker.java b/src/main/java/org/extism/chicory/sdk/Linker.java
index 8c464aa..544ed09 100644
--- a/src/main/java/org/extism/chicory/sdk/Linker.java
+++ b/src/main/java/org/extism/chicory/sdk/Linker.java
@@ -36,21 +36,24 @@ Plugin link() {
var dg = new DependencyGraph(logger);
Map config;
+ String[] allowedHosts;
WasiOptions wasiOptions;
CachedAotMachineFactory aotMachineFactory;
if (manifest.options == null) {
config = Map.of();
+ allowedHosts = new String[0];
wasiOptions = null;
aotMachineFactory = null;
} else {
dg.setOptions(manifest.options);
config = manifest.options.config;
+ allowedHosts = manifest.options.allowedHosts;
wasiOptions = manifest.options.wasiOptions;
aotMachineFactory = manifest.options.aot? new CachedAotMachineFactory() : null;
}
// Register the HostEnv exports.
- var hostEnv = new HostEnv(new Kernel(aotMachineFactory), config, logger);
+ var hostEnv = new HostEnv(new Kernel(aotMachineFactory), config, allowedHosts, logger);
dg.registerFunctions(hostEnv.toHostFunctions());
// Register the WASI host functions.
diff --git a/src/main/java/org/extism/chicory/sdk/Manifest.java b/src/main/java/org/extism/chicory/sdk/Manifest.java
index ba0d363..0c54f1f 100644
--- a/src/main/java/org/extism/chicory/sdk/Manifest.java
+++ b/src/main/java/org/extism/chicory/sdk/Manifest.java
@@ -2,6 +2,7 @@
import com.dylibso.chicory.wasi.WasiOptions;
+import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
@@ -17,6 +18,7 @@ public static class Options {
EnumSet validationFlags = EnumSet.noneOf(Validation.class);
Map config;
WasiOptions wasiOptions;
+ String[] allowedHosts;
public Options withAoT() {
this.aot = true;
@@ -33,6 +35,17 @@ public Options withValidation(Validation... vs) {
return this;
}
+ public Options withAllowedHosts(String... allowedHosts) {
+ for (String allowedHost : allowedHosts) {
+ // Wildcards are only allowed at starting position and may occur only once.
+ if (allowedHost.indexOf('*') > 0 || allowedHost.indexOf('*', 1) != -1) {
+ throw new ExtismException("Illegal pattern " + allowedHost);
+ }
+ }
+ this.allowedHosts = allowedHosts;
+ return this;
+ }
+
public Options withWasi(WasiOptions wasiOptions) {
this.wasiOptions = wasiOptions;
return this;
diff --git a/src/test/java/org/extism/chicory/sdk/HostEnvTest.java b/src/test/java/org/extism/chicory/sdk/HostEnvTest.java
index e12b382..27e1511 100644
--- a/src/test/java/org/extism/chicory/sdk/HostEnvTest.java
+++ b/src/test/java/org/extism/chicory/sdk/HostEnvTest.java
@@ -1,8 +1,12 @@
package org.extism.chicory.sdk;
import com.dylibso.chicory.log.SystemLogger;
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
import junit.framework.TestCase;
+import java.io.ByteArrayInputStream;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@@ -11,7 +15,7 @@ public void testShowcase() {
var logger = new SystemLogger();
var config = Map.of("key", "value");
- var hostEnv = new HostEnv(new Kernel(), config, logger);
+ var hostEnv = new HostEnv(new Kernel(), config, new String[0], logger);
assertEquals(hostEnv.config().get("key"), "value");
@@ -25,4 +29,27 @@ public void testShowcase() {
long ptr = hostEnv.memory().alloc(size);
assertEquals(hostEnv.memory().length(ptr), size);
}
+
+ public void testHttp() {
+ var logger = new SystemLogger();
+ var hostEnv = new HostEnv(new Kernel(), Map.of(), new String[0], logger);
+
+ byte[] response = hostEnv.http().request(
+ "GET",
+ URI.create("http://httpbin.org/headers"),
+ Map.of("X-Custom-Header", "hello"),
+ new byte[0]);
+ JsonObject responseObject = Json.createReader(new ByteArrayInputStream(response)).readObject();
+ assertEquals("hello", responseObject.getJsonObject("headers").getString("X-Custom-Header"));
+
+ byte[] response2 = hostEnv.http().request(
+ "POST",
+ URI.create("http://httpbin.org/post"),
+ Map.of(),
+ "hello".getBytes(StandardCharsets.UTF_8));
+
+
+ JsonObject responseObject2 = Json.createReader(new ByteArrayInputStream(response2)).readObject();
+ assertEquals("hello", responseObject2.getString("data"));
+ }
}