allthingssecurity commented on code in PR #27678:
URL: https://github.com/apache/camel/pull/27678#discussion_r4249690190


##########
components/camel-wasm/src/main/java/org/apache/camel/component/wasm/WasmProducer.java:
##########
@@ -16,76 +16,404 @@
  */
 package org.apache.camel.component.wasm;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
-import org.apache.camel.Endpoint;
+import org.apache.camel.CamelExchangeException;
 import org.apache.camel.Exchange;
-import org.apache.camel.spi.Resource;
-import org.apache.camel.spi.ResourceLoader;
+import org.apache.camel.ExchangeTimedOutException;
+import org.apache.camel.Message;
+import org.apache.camel.StreamCache;
 import org.apache.camel.support.DefaultProducer;
-import org.apache.camel.support.PluginHelper;
-import org.apache.camel.wasm.WasmFunction;
-import org.apache.camel.wasm.WasmSupport;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.util.ObjectHelper;
+import org.apache.camel.util.URISupport;
+import org.apache.camel.wasm.WasiCommand;
+import org.apache.camel.wasm.Wasm;
+import run.endive.runtime.TrapException;
 import run.endive.wasm.Parser;
 import run.endive.wasm.WasmModule;
 
+/**
+ * Runs a WASI Preview 1 command module once per exchange: the message body is 
the standard input of the module and its
+ * standard output becomes the message body.
+ * <p/>
+ * The module is parsed once, when the producer is initialized, and every 
exchange runs on a new instance of it, so
+ * exchanges never share guest memory or globals and need no lock. The 
instance is dropped after its run, whatever the
+ * outcome.
+ * <p/>
+ * Without a timeout the module runs on the calling thread and reads the body 
as a stream. With a timeout the module
+ * runs on a thread of the producer, and the body is first read into memory on 
the calling thread: the module then reads
+ * a private copy, so a module that is abandoned after a timeout never reads a 
stream that the route is still using.
+ * <p/>
+ * When the exchange fails, the producer does not set a new body, but the 
module may have read the old one: a body that
+ * is a stream has been read, partly or to its end. With stream caching (the 
default) the body can be read again: Camel
+ * resets a {@link StreamCache}. When a timeout is set, a body that was a 
stream but not a StreamCache is replaced by
+ * the bytes that were read from it, so that error handling still sees the 
content.
+ */
 public class WasmProducer extends DefaultProducer {
 
-    private final String functionModule;
-    private final String functionName;
+    private static final int MAX_STDERR_IN_MESSAGE = 256;
+    private static final String DEFAULT_PROGRAM_NAME = "module.wasm";
 
-    private WasmModule module;
-    private WasmFunction function;
+    private WasiCommand command;
+    private Map<String, String> environment = Collections.emptyMap();
+    private List<String> environmentHeaders = Collections.emptyList();
+    private volatile ExecutorService executor;
 
-    public WasmProducer(Endpoint endpoint, String functionModule, String 
functionName) throws Exception {
+    public WasmProducer(WasmEndpoint endpoint) {
         super(endpoint);
+    }
 
-        this.functionModule = functionModule;
-        this.functionName = functionName;
+    @Override
+    public WasmEndpoint getEndpoint() {
+        return (WasmEndpoint) super.getEndpoint();
     }
 
     @Override
-    public void doInit() throws Exception {
-        final ResourceLoader rl = 
PluginHelper.getResourceLoader(getEndpoint().getCamelContext());
-        final Resource res = rl.resolveResource(this.functionModule);
+    protected void doInit() throws Exception {
+        super.doInit();
 
-        try (InputStream is = res.getInputStream()) {
-            this.module = Parser.parse(is);
+        WasmEndpoint endpoint = getEndpoint();
+        WasmConfiguration conf = endpoint.getConfiguration();
+
+        Map<String, String> env = new LinkedHashMap<>();
+        if (conf.getEnvironment() != null) {
+            for (Map.Entry<String, Object> e : 
conf.getEnvironment().entrySet()) {
+                String value = e.getValue() != null ? e.getValue().toString() 
: "";
+                env.put(checkName(e.getKey()), checkNoNul("environment 
variable " + e.getKey(), value));
+            }
+        }
+        List<String> headers = new ArrayList<>();
+        if (ObjectHelper.isNotEmpty(conf.getEnvironmentHeaders())) {
+            for (String header : conf.getEnvironmentHeaders().split(",")) {
+                String name = checkName(header.trim());
+                if (env.containsKey(name)) {
+                    throw new IllegalArgumentException(
+                            "Environment variable " + name + " is configured 
both in environment and environmentHeaders");
+                }
+                headers.add(name);
+            }
         }
+        List<String> arguments = arguments(programName(endpoint.getModule()), 
conf.getArgs());
+        for (String argument : arguments) {
+            checkNoNul("argument " + argument, argument);
+        }
+
+        WasmModule module;
+        try (InputStream is = 
ResourceHelper.resolveMandatoryResourceAsInputStream(
+                endpoint.getCamelContext(), endpoint.getModule())) {
+            module = Parser.parse(is);
+        }
+
+        this.environment = Collections.unmodifiableMap(env);
+        this.environmentHeaders = List.copyOf(headers);
+        this.command = new WasiCommand(module, arguments, 
conf.getMaxMemoryPages());
     }
 
     @Override
-    public void doStart() throws Exception {
+    protected void doStart() throws Exception {
         super.doStart();
 
-        if (this.module != null && this.function == null) {
-            this.function = new WasmFunction(this.module, this.functionName);
+        if (getEndpoint().getConfiguration().getTimeout() > 0 && executor == 
null) {
+            executor = 
getEndpoint().getCamelContext().getExecutorServiceManager()
+                    .newCachedThreadPool(this, "Wasm");
         }
     }
 
     @Override
-    public void doStop() throws Exception {
+    protected void doStop() throws Exception {
+        ExecutorService pool = executor;
+        executor = null;
+        if (pool != null) {
+            // interrupts the modules that are still running
+            
getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(pool);
+        }
+
         super.doStop();
+    }
+
+    @Override
+    public void process(Exchange exchange) throws Exception {
+        WasmConfiguration conf = getEndpoint().getConfiguration();
+        Message message = exchange.getMessage();
 
-        if (this.function != null) {
-            this.function.close();
+        // the result headers only ever describe this run: never a previous 
one, nor a value sent with the message
+        message.removeHeader(Wasm.Headers.EXIT_CODE);
+        message.removeHeader(Wasm.Headers.STDERR);
+
+        Map<String, String> env = environment(message);
+        // no body is empty standard input; a body that cannot be read as a 
stream fails the exchange instead of
+        // silently running the module with empty input
+        Object body = message.getBody();
+        InputStream in = body != null ? 
message.getMandatoryBody(InputStream.class) : InputStream.nullInputStream();
+        byte[] restore = null;
+        if (conf.getTimeout() > 0 && body != null) {
+            // with a timeout the module runs on another thread, which goes on 
for a moment after the timeout and is
+            // not stopped by an interrupt while it is blocked in a read of 
the body. So the body is read here, on the
+            // calling thread, and the module gets a copy that no other thread 
uses and that never blocks.
+            byte[] input = in.readAllBytes();
+            in = new ByteArrayInputStream(input);

Review Comment:
   Fixed in e4309cfcd7a7. Checked first: for a `File`, `Path` and file-consumer 
`GenericFile` body
   `getMandatoryBody(InputStream.class)` opens a new file stream on every call 
(`IOHelper.toInputStream`,
   `GenericFileConverter.genericFileToInputStream`), and nothing closed it. The 
producer now closes a stream that the
   converter opened: right after `readAllBytes()` when a timeout is set (in a 
`finally`), and in the outer `finally`
   otherwise. Without a timeout the module runs on the calling thread (`run()` 
calls `task.call()` directly), so it has
   ended when the `finally` runs and no other thread reads the stream.
   
   One difference from the suggestion: `in != body` alone is not enough to know 
that the converter created the stream.
   For a `StreamSource` body `XmlConverter.toInputStream(StreamSource)` returns 
the stream the body holds (also for a
   `StreamSourceCache`, which is a `StreamCache` but not an `InputStream`), and 
closing it would close the caller's
   stream. So the stream is closed when `in != body` and the body is neither a 
`StreamCache` nor an XML `Source`.
   With `in != body` only, the 4 runs of the new `streamOfTheCallerIsNotClosed` 
fail.
   
   Tests: `streamOpenedByTheTypeConverterIsClosed` (a body type whose converter 
opens a recording stream; without and
   with a timeout, on success and on a non-zero exit: exactly one stream, 
closed), `fileBodyIsStandardInputAndCanBeDeletedAfterwards`
   (a temp `File` body, without and with a timeout), 
`streamOfTheCallerIsNotClosed` (a stream body and a `StreamSource`
   body, same four cases). Without the fix the 4 runs of 
`streamOpenedByTheTypeConverterIsClosed` fail. camel-wasm with
   `install`: 65 tests, 0 failures (was 55). The PR description is updated.
   
   _Claude Code on behalf of allthingssecurity_



##########
components/camel-wasm/src/main/docs/wasm-component.adoc:
##########
@@ -43,133 +58,169 @@ include::partial$component-endpoint-headers.adoc[]
 
 == Usage
 
-=== Writing A Wasm processor
-
-In _Wasm_, sharing objects between the host, in this case the _JVM_, and the 
_Wasm_ module is deliberately restricted and as of today, it requires a number 
of steps:
-
-1. From the _host_, call a function inside the webassembly module that 
allocates a block of memory and returns its address, then save it
-2. From the _host_, write the data that should be exchanged with the _Wasm_ 
module to the saved address
-3. From the _host_, invoke the required function passing both the address 
where the data is written and its size
-4. From the _Wasm_ module, read the data and process it
-5. From the _host_, release the memory when done
+=== Writing a module
 
-==== Providing functions for memory management
-
-The module hosting the function *must* provide the functions to 
allocate/deallocate memory that *must* be named `alloc` and `dealloc` 
respectively.
-
-Here's an example of the mentioned functions implemented in 
https://www.rust-lang.org[Rust]:
+A module is an ordinary program that reads its standard input and writes its 
standard output. For example, in
+https://www.rust-lang.org[Rust]:
 
 [source,rust]
 ----
-pub extern "C" fn alloc(size: u32) -> *mut u8 {
-    let mut buf = Vec::with_capacity(size as usize);
-    let ptr = buf.as_mut_ptr();
-
-    // tell Rust not to clean this up
-    mem::forget(buf);
+use std::io::{self, Read, Write};
 
-    ptr
-}
-
-pub unsafe extern "C" fn dealloc(ptr: &mut u8, len: i32) {
-    // Retakes the pointer which allows its memory to be freed.
-    let _ = Vec::from_raw_parts(ptr, 0, len as usize);
+fn main() {
+    let mut input = String::new();
+    io::stdin().read_to_string(&mut input).unwrap();
+    io::stdout().write_all(input.to_uppercase().as_bytes()).unwrap();
 }
 ----
 
-==== Data shapes
+Built with `rustup target add wasm32-wasip1` and `cargo build --release 
--target wasm32-wasip1`.
 
-It is not possible to share a Java object with the Wasm module directly, and 
as mentioned before,
-data exchange leverages Wasm's memory that can be accessed by both the host 
and the guest runtimes.
-At this stage, the data structure that the component exchange with the Wasm 
function is a subset of the Apache Camel Message,
-containing headers the body encoded as a base64 string:
+The same program in https://go.dev[Go]:
 
-._Java-only: data exchange wrapper class for Wasm module communication_
-[source,java]
+[source,go]
 ----
-public static class Wrapper {
-    @JsonProperty
-    public Map<String, String> headers = new HashMap<>();
-
-    @JsonProperty
-    public byte[] body;
-}
-----
-
-==== Data processing
-
-The component expects the processing function to have the following signature:
-
-[source,rust]
-----
-fn function(ptr: u32, len: u32) -> u64
-----
-
-* it accepts two 32bit unsigned integers arguments
-** a pointer to the memory location when the input data has been written 
(`ptr`)
-** the size of the input data (`len`)
-* it returns a 64bit unsigned integer where:
-** the first 32bit represents a pointer to the return data
-** the last 31bit represents the size of the return data, which must have the 
same data shape discussed above unless there is an error (see below)
-** the most significant bit of the returned data size is reserved to signal an 
error, so if it is set, then the return data could contain an error 
message/code/etc
-
-Here's an example of a complete function:
-
-[source,rust]
-----
-#[derive(Serialize, Deserialize)]
-struct Message {
-    headers: HashMap<String, serde_json::Value>,
-
-    #[serde(with = "Base64Standard")]
-    body: Vec<u8>,
-}
-
-#[cfg_attr(all(target_arch = "wasm32"), export_name = "process")]
-#[no_mangle]
-pub extern fn process(ptr: u32, len: u32) -> u64 {
-    let bytes = unsafe {
-        slice::from_raw_parts_mut(
-            ptr as *mut u8,
-            len as usize)
-    };
+package main
 
-    let mut msg: Message = serde_json::from_slice(bytes).unwrap();
+import (
+    "bytes"
+    "io"
+    "os"
+)
 
-
-    let out_vec = serde_json::to_vec(&msg).unwrap();
-    let out_len = out_vec.len();
-    let out_ptr = alloc(out_len as u32);
-
-    unsafe {
-        std::ptr::copy_nonoverlapping(
-            out_vec.as_ptr(),
-            out_ptr,
-            out_len as usize)
-    };
-
-    return ((out_ptr as u64) << 32) | out_len as u64;
+func main() {
+    input, _ := io.ReadAll(os.Stdin)
+    os.Stdout.Write(bytes.ToUpper(input))
 }
 ----
 
-==== Errors and module state
-
-The module is instantiated once per producer (each `to` of a `wasm` endpoint 
in a route has its own instance) and that
-instance handles every message, one at a time, so data the module keeps in its 
memory or globals carries over from one
-message to the next.
-
-* When the function returns with the error bit set, the error data is released 
with `dealloc` using its size without
-the error bit, the error data becomes the message of the exception, and the 
instance is kept.
-* When the call fails without returning, for example on a Rust `panic` or any 
other Wasm trap, the instance is
-discarded and a new one is created from the module for the next message. 
Whatever the failed call allocated can no
-longer be released, so keeping the instance would grow its memory with every 
failure. Any state the module kept in
-the discarded instance is lost.
-On a route where every call fails this way, the module is instantiated again 
for every message (its data segments
-are copied and any start function runs), which costs more than a call on a 
kept instance.
+Built with `GOOS=wasip1 GOARCH=wasm go build -o upper.wasm`.
+
+=== Running a module
+
+For every exchange the component:
+
+. creates a new instance of the module (the module is parsed once, when the 
producer starts),
+. runs its `_start` function with the message body as standard input, the 
arguments configured with `args` and
+the environment variables configured with `environment` and 
`environmentHeaders`,
+. sets the `CamelWasmExitCode` header to the exit code of the program: 0 when 
`_start` returns, otherwise the code
+the program passed to `proc_exit`,
+. sets the `CamelWasmStderr` header to what the program wrote to its standard 
error, if anything,
+. and replaces the message body with the standard output of the program, as a 
`byte[]`.
+
+When the program exits with a non-zero code, the exchange fails with a 
`WasmExitCodeException` (which holds the exit
+code and the standard error) and the standard output is not set as the body, 
unless `failOnNonZeroExit` is `false`:
+then the standard output replaces the body as for any other exit code. The 
other headers of the message are kept.
+See <<the-message-body-after-a-failure>> for what the body holds when the 
exchange fails.
+
+Every exchange runs on its own instance, so exchanges never share the memory 
or the globals of the module and can
+run in parallel without a lock. A module cannot keep state from one message to 
the next.
+
+The `CamelWasmExitCode` and `CamelWasmStderr` headers always describe the 
current run: a value already on the
+message is removed before the module runs.
+
+=== What the module can access
+
+* *Standard input, output and error*: the message body, the new body and the 
`CamelWasmStderr` header. They are
+not terminals.
+* *Arguments*: the file name of the module as argument zero (`upper.wasm` for 
`wasm:file:/opt/modules/upper.wasm`;
+the directories, the host, user info and query of the location are not passed 
on), followed by the `args` option, for
+example `args=--level 2`. Use double quotes around an argument that contains 
spaces. Arguments are fixed by the route.
+* *Environment variables*: only the ones configured with 
`environment.NAME=value`, and the message headers listed in
+`environmentHeaders`, passed under the name they are listed with. Header names 
are matched the way Camel matches
+them, ignoring case: `environmentHeaders=TENANT` also passes a header named 
`tenant`, as the variable `TENANT`. A
+listed header that is not on the message is not set. Headers usually come from 
whoever sent the message, so no
+header reaches the module unless it is listed. The environment of the JVM is 
never passed on.
+* *Files and network*: none. The module gets no preopened directories, so it 
cannot open files, and no sockets.
+* *Clock*: the one of the host.
+* *Random numbers*: WASI `random_get` (used by Go `crypto/rand` and Rust 
`getrandom`) returns bytes from a
+`java.security.SecureRandom` of the host.
+
+=== Limits
+
+The following options bound what one run of a module can use. They are off by 
default.
+
+* `maxMemoryPages`: the maximum size of the memory of the module, in pages of 
64 KiB. Rust and Go programs declare
+no maximum by default, so the memory of one run can grow to gigabytes of JVM 
heap. When the limit is reached, growing
+the memory fails inside the module, which then usually traps or exits with an 
error.
+* `timeout`: the maximum time one run may take. The module then runs on a 
thread of the producer while the route
+thread waits. When the time is up, the module is interrupted at its next 
function call or loop iteration and the
+exchange fails with an `ExchangeTimedOutException`. Without a `timeout`, a 
module that never ends blocks the route
+thread.
+* `maxOutputSize`: the maximum number of bytes the module may write to its 
standard output, and separately to its
+standard error. Both are buffered in memory. A module that writes more is 
stopped and the exchange fails.
+
+WARNING: Without `maxOutputSize` the standard output and the standard error 
are buffered without a limit: a module
+that keeps writing fills the heap of the JVM, which affects the whole 
application and not only one exchange. Set
+`maxOutputSize` unless the output size of the module is known and small, for 
example `maxOutputSize=16777216`
+(16 MiB).
+
+With a `timeout`, the message body is read into memory, on the route thread, 
before the module starts, and the module
+reads that copy. Without a `timeout` the body is streamed to the module. The 
copy is what makes the timeout safe: the
+thread of a module that timed out can run for a moment longer, and it must not 
read a stream that the route is using
+again. It also means that:
+
+* the whole body is on the heap for the time of the run, in addition to the 
buffered output,
+* the time to read the body is not part of the `timeout`: a body stream that 
delivers slowly, such as a slow network
+connection, holds the route thread as it does without a `timeout`,
+* the threads of the producer are never blocked on the body, so a module that 
timed out gives its thread back at its
+next function call or loop iteration. The pool of these threads has no maximum 
size and needs none: it holds one
+thread for each exchange that is in the endpoint at the same time.
+
+These limits are robustness controls, not a sandbox. A module is trusted code 
chosen by the route author, like a
+processor or a bean, as described in the 
xref:manual::security-model.adoc[security model]: the limits keep a
+misbehaving module from turning one message into an unbounded use of memory, 
time or threads, but they are not a
+defence against a module written to attack the host.
+
+=== Errors
+
+[width="100%",cols="2,4",options="header"]
+|===
+| What happens | Result
+| The program exits with code 0 | The body is the standard output. 
`CamelWasmExitCode` is 0.
+| The program exits with another code | A `WasmExitCodeException`, no new 
body, `CamelWasmExitCode` and
+`CamelWasmStderr` set. With `failOnNonZeroExit=false`: the body is the 
standard output.
+| The program traps, for example on a Rust `panic` | A 
`CamelExchangeException` caused by the `TrapException`
+of Endive, with the start of the standard error in its message. 
`CamelWasmStderr` is set, `CamelWasmExitCode` is not.
+| The program writes more than `maxOutputSize` | A `CamelExchangeException`. 
`CamelWasmStderr` is set when the program
+wrote to its standard error, `CamelWasmExitCode` is not.
+| The program runs longer than `timeout` | An `ExchangeTimedOutException`. 
Neither header is set.
+| The module is not a WASI Preview 1 command module, or the options are 
invalid | The route fails to start.
+|===
+
+In every case, the instance the program ran on is discarded.
+
+[[the-message-body-after-a-failure]]

Review Comment:
   Done in e4309cfcd7a7: the `[[the-message-body-after-a-failure]]` line is 
removed and the link at line 114 is
   `<<_the_message_body_after_a_failure>>`, in `wasm-component.adoc` and in the 
catalog copy (the two files are
   identical).
   
   _Claude Code on behalf of allthingssecurity_



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to