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")); + } }