This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new f3ef837e36b9 CAMEL-25463: camel-langchain4j-ingest - read a body only
up to maxDocumentSize (#27569)
f3ef837e36b9 is described below
commit f3ef837e36b9fb952f19e913424a8bf277d7be88
Author: Jiří Ondrušek <[email protected]>
AuthorDate: Thu Oct 8 18:53:42 2026 +0200
CAMEL-25463: camel-langchain4j-ingest - read a body only up to
maxDocumentSize (#27569)
A body not yet in memory (a stream, a file) was read whole before
maxDocumentSize was checked. It is now read only one byte, or character,
past the cap, which is enough to refuse it. With a cap, a File or Path
body decodes strictly, like a file-consumer body.
The text cap is now checked before the blank check, since a capped read
sees only the start of the body. An oversized body that is all blank
now fails; it answered EMPTY before.
Stream caching, on by default, still reads a stream whole before the
endpoint, into memory unless it spools to disk; the docs say so.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../catalog/docs/langchain4j-ingest-component.adoc | 10 ++-
.../main/docs/langchain4j-ingest-component.adoc | 10 ++-
.../langchain4j/ingest/IngestService.java | 20 +++---
.../ingest/LangChain4jIngestProducer.java | 73 +++++++++++++++++++---
.../ingest/LangChain4jIngestMediaTest.java | 44 +++++++++++--
.../ingest/LangChain4jIngestProducerTest.java | 66 +++++++++++++++++++
6 files changed, 195 insertions(+), 28 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-ingest-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-ingest-component.adoc
index 114985860897..b9e2977f79bb 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-ingest-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-ingest-component.adoc
@@ -201,6 +201,11 @@ memory- and CPU-expensive and a small file can still parse
into a lot of text. A
document fails the exchange like any other error (see below) and releases its
dedup claim, so
a trimmed re-delivery under the same id still ingests.
+A body that is not yet a String, such as a file or a stream, is read only one
character past
+the cap. Stream caching, on by default, reads a stream into memory before the
route reaches
+the endpoint, unless it spools to disk: on a consumer-fed pipeline, limit the
size at the
+consumer as well, or enable spooling.
+
=== Ingesting media: images, audio, video and PDF
With `modality=media` the body is read as bytes and embedded whole, as one
vector, by a
@@ -239,8 +244,9 @@ apply, a configured `documentSplitter` fails the start, and
`maxDocumentSize` an
`minDocumentSize` count bytes. Base64 encoding adds a third, so set
`maxDocumentSize`
against the model's request limit. A file consumer announces the length in
`CamelFileLength`, which is checked against the cap before the file is read;
any other
-body is checked once it is in memory. A `documentFilter` runs before the body
is read and
-sees it as delivered, a file or stream rather than text, so filter on headers
or the id.
+body not yet in memory is read only one byte past the cap. A `documentFilter`
runs before
+the body is read and sees it as delivered, a file or stream rather than text,
so filter on
+headers or the id.
Several LangChain4j providers embed images (Jina, Voyage AI, Cohere, Google
Gemini and
Bedrock Titan among them); none embeds audio yet, so an audio model is
typically a custom
diff --git
a/components/camel-ai/camel-langchain4j-ingest/src/main/docs/langchain4j-ingest-component.adoc
b/components/camel-ai/camel-langchain4j-ingest/src/main/docs/langchain4j-ingest-component.adoc
index 114985860897..b9e2977f79bb 100644
---
a/components/camel-ai/camel-langchain4j-ingest/src/main/docs/langchain4j-ingest-component.adoc
+++
b/components/camel-ai/camel-langchain4j-ingest/src/main/docs/langchain4j-ingest-component.adoc
@@ -201,6 +201,11 @@ memory- and CPU-expensive and a small file can still parse
into a lot of text. A
document fails the exchange like any other error (see below) and releases its
dedup claim, so
a trimmed re-delivery under the same id still ingests.
+A body that is not yet a String, such as a file or a stream, is read only one
character past
+the cap. Stream caching, on by default, reads a stream into memory before the
route reaches
+the endpoint, unless it spools to disk: on a consumer-fed pipeline, limit the
size at the
+consumer as well, or enable spooling.
+
=== Ingesting media: images, audio, video and PDF
With `modality=media` the body is read as bytes and embedded whole, as one
vector, by a
@@ -239,8 +244,9 @@ apply, a configured `documentSplitter` fails the start, and
`maxDocumentSize` an
`minDocumentSize` count bytes. Base64 encoding adds a third, so set
`maxDocumentSize`
against the model's request limit. A file consumer announces the length in
`CamelFileLength`, which is checked against the cap before the file is read;
any other
-body is checked once it is in memory. A `documentFilter` runs before the body
is read and
-sees it as delivered, a file or stream rather than text, so filter on headers
or the id.
+body not yet in memory is read only one byte past the cap. A `documentFilter`
runs before
+the body is read and sees it as delivered, a file or stream rather than text,
so filter on
+headers or the id.
Several LangChain4j providers embed images (Jina, Voyage AI, Cohere, Google
Gemini and
Bedrock Titan among them); none embeds audio yet, so an audio model is
typically a custom
diff --git
a/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/IngestService.java
b/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/IngestService.java
index 4c629fbc7cf1..29837e000ed0 100644
---
a/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/IngestService.java
+++
b/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/IngestService.java
@@ -85,23 +85,25 @@ class IngestService {
if (documentId == null || documentId.isBlank()) {
throw new IllegalArgumentException("Ingestion pipeline '" +
pipeline + "': documentId is required");
}
+ // the pipeline is whole-document-in-memory by design, so the cap is
the protection
+ // against oversized - and, on a consumer-fed pipeline, attacker-sized
- payloads. The
+ // failure releases a dedup claim like any other, so a trimmed
re-delivery still ingests.
+ // It comes first: the producer may hand over only the start of an
oversized stream, and
+ // a blank start says nothing about the rest
+ if (text != null && maxDocumentSize > 0 && text.length() >
maxDocumentSize) {
+ throw new IllegalArgumentException(
+ "Ingestion pipeline '" + pipeline + "': document '" +
documentId + "' exceeds maxDocumentSize ("
+ + text.length() + " > " +
maxDocumentSize + " characters)");
+ }
if (text == null || text.isBlank()) {
return new IngestResult(pipeline, documentId, 0,
IngestResult.Outcome.EMPTY);
}
// a benign filter, not a guard: a too-short document answers
FILTERED, which releases
- // a dedup claim like EMPTY does. Deliberately soft where the max
check below throws -
+ // a dedup claim like EMPTY does. Deliberately soft where the max
check above throws -
// undersized is a data decision, oversized a resource risk - so do
not align the two
if (minDocumentSize > 0 && text.length() < minDocumentSize) {
return new IngestResult(pipeline, documentId, 0,
IngestResult.Outcome.FILTERED);
}
- // the pipeline is whole-document-in-memory by design, so the cap is
the protection
- // against oversized - and, on a consumer-fed pipeline, attacker-sized
- payloads. The
- // failure releases a dedup claim like any other, so a trimmed
re-delivery still ingests
- if (maxDocumentSize > 0 && text.length() > maxDocumentSize) {
- throw new IllegalArgumentException(
- "Ingestion pipeline '" + pipeline + "': document '" +
documentId + "' exceeds maxDocumentSize ("
- + text.length() + " > " +
maxDocumentSize + " characters)");
- }
// the document id travels with every segment: retrieval can cite it,
and the engine that
// replaces this one needs it to find a document's vectors again
diff --git
a/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducer.java
b/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducer.java
index efc3cb0566a0..50bea6afe60e 100644
---
a/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducer.java
+++
b/components/camel-ai/camel-langchain4j-ingest/src/main/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducer.java
@@ -16,6 +16,10 @@
*/
package org.apache.camel.component.langchain4j.ingest;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
@@ -279,7 +283,7 @@ public class LangChain4jIngestProducer extends
DefaultProducer {
* {@link IngestService}) needs the text - both run after the dedup claim,
unlike the id patterns.
*/
private IngestResult ingestUnlessFiltered(Exchange exchange, String
documentId, String mimeType, ContentType medium)
- throws InvalidPayloadException {
+ throws InvalidPayloadException, IOException {
Predicate filter = configuration.getDocumentFilter();
if (filter != null && !filter.matches(exchange)) {
return filtered(documentId);
@@ -290,15 +294,64 @@ public class LangChain4jIngestProducer extends
DefaultProducer {
if (announced != null) {
mediaService.checkSize(documentId, announced);
}
- // bytes, not a String - a charset conversion would corrupt them.
A null body stays
- // EMPTY; anything else must convert, so a wrong body type (a
POJO, say) fails as an
- // InvalidPayloadException instead of being answered EMPTY by a
silent null conversion
- byte[] bytes = exchange.getMessage().getBody() == null
- ? null
- : exchange.getMessage().getMandatoryBody(byte[].class);
- return mediaService.ingest(documentId, bytes, mimeType, medium);
- }
- return textService.ingest(documentId,
exchange.getMessage().getBody(String.class));
+ return mediaService.ingest(documentId, mediaBody(exchange),
mimeType, medium);
+ }
+ return textService.ingest(documentId, textBody(exchange));
+ }
+
+ /**
+ * The media body as bytes, not a String - a charset conversion would
corrupt them. A null body stays EMPTY;
+ * anything else must convert, so a wrong body type (a POJO, say) fails as
an InvalidPayloadException instead of
+ * being answered EMPTY by a silent null conversion. With maxDocumentSize
set, a body not yet in memory is read only
+ * one byte past the cap, enough for the size check to refuse it.
+ */
+ private byte[] mediaBody(Exchange exchange) throws
InvalidPayloadException, IOException {
+ Object body = exchange.getMessage().getBody();
+ int limit = readLimit();
+ InputStream stream = body == null || body instanceof byte[] || limit < 0
+ ? null : exchange.getMessage().getBody(InputStream.class);
+ if (stream == null) {
+ return body == null ? null :
exchange.getMessage().getMandatoryBody(byte[].class);
+ }
+ try (stream) {
+ return stream.readNBytes(limit);
+ }
+ }
+
+ /**
+ * The text body. With maxDocumentSize set, a body not yet a String is
read only one character past the cap, enough
+ * for the size check to refuse it.
+ */
+ private String textBody(Exchange exchange) throws IOException {
+ Object body = exchange.getMessage().getBody();
+ int limit = readLimit();
+ Reader reader = body == null || body instanceof String || limit < 0
+ ? null : readerOf(exchange);
+ if (reader == null) {
+ return exchange.getMessage().getBody(String.class);
+ }
+ try (reader) {
+ StringBuilder text = new StringBuilder();
+ char[] chunk = new char[8192];
+ int read;
+ while (text.length() < limit
+ && (read = reader.read(chunk, 0, Math.min(chunk.length,
limit - text.length()))) != -1) {
+ text.append(chunk, 0, read);
+ }
+ return text.toString();
+ }
+ }
+
+ /** The body as a Reader, or null; a File or a Path converts to a
BufferedReader, not to a Reader. */
+ private static Reader readerOf(Exchange exchange) {
+ Reader reader = exchange.getMessage().getBody(Reader.class);
+ return reader != null ? reader :
exchange.getMessage().getBody(BufferedReader.class);
+ }
+
+ /** How far a body is read: one unit past maxDocumentSize, so an oversized
one shows; -1 without a cap. */
+ private int readLimit() {
+ int max = configuration.getMaxDocumentSize();
+ return max <= 0 ? -1 : max == Integer.MAX_VALUE ? max : max + 1;
}
private IngestResult filtered(String documentId) {
diff --git
a/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestMediaTest.java
b/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestMediaTest.java
index 322d7dc22f65..ca4343a1d138 100644
---
a/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestMediaTest.java
+++
b/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestMediaTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.component.langchain4j.ingest;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
@@ -27,6 +28,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
import dev.langchain4j.data.message.ContentType;
import dev.langchain4j.data.segment.TextSegment;
@@ -83,6 +85,10 @@ class LangChain4jIngestMediaTest extends CamelTestSupport {
from("direct:capped")
.to("langchain4j-ingest:capped?modality=media&documentIdHeader=CamelFileName"
+ "&maxDocumentSize=100");
+ // without stream caching, which would read the whole stream
before the endpoint does
+ from("direct:capped-stream").noStreamCaching()
+
.to("langchain4j-ingest:capped-stream?modality=media&documentIdHeader=CamelFileName"
+ + "&maxDocumentSize=100");
from("direct:min")
.to("langchain4j-ingest:min?modality=media&documentIdHeader=CamelFileName"
+ "&minDocumentSize=1000000");
@@ -159,11 +165,13 @@ class LangChain4jIngestMediaTest extends CamelTestSupport
{
assertThat(result.outcome()).isEqualTo(IngestResult.Outcome.EMPTY);
// a POJO has no converter to bytes: a silent null conversion would
answer EMPTY and
- // release the claim, hiding a wiring mistake - it must fail instead
- assertThatThrownBy(() -> template.requestBodyAndHeader("direct:media",
new Object(),
- Exchange.FILE_NAME, "pojo.wav"))
- .isInstanceOf(CamelExecutionException.class)
- .hasStackTraceContaining("InvalidPayloadException");
+ // release the claim, hiding a wiring mistake - it must fail instead,
with a cap or without
+ for (String route : new String[] { "direct:media", "direct:capped" }) {
+ assertThatThrownBy(() -> template.requestBodyAndHeader(route, new
Object(),
+ Exchange.FILE_NAME, "pojo.wav"))
+ .isInstanceOf(CamelExecutionException.class)
+ .hasStackTraceContaining("InvalidPayloadException");
+ }
}
@Test
@@ -194,6 +202,32 @@ class LangChain4jIngestMediaTest extends CamelTestSupport {
assertThat(stackTraceOf(thrown)).as("the body must not have been
read").doesNotContain("body must not be read");
}
+ @Test
+ void oversizedStreamIsReadOnlyPastTheCap() {
+ // a stream is read one byte past the cap, not whole, even when its
announced length is a lie
+ AtomicLong read = new AtomicLong();
+ InputStream tenMegabytes = new InputStream() {
+ @Override
+ public int read() {
+ if (read.get() >= 10_000_000) {
+ return -1;
+ }
+ read.incrementAndGet();
+ return 0;
+ }
+ };
+ Throwable thrown = catchThrowable(() ->
template.requestBodyAndHeaders("direct:capped-stream", tenMegabytes,
+ Map.<String, Object> of(Exchange.FILE_NAME, "big.wav",
Exchange.FILE_LENGTH, 10L)));
+
+ assertThat(thrown).isInstanceOf(CamelExecutionException.class)
+ .hasStackTraceContaining("exceeds maxDocumentSize (101 > 100
bytes)");
+ assertThat(read.get()).isEqualTo(101);
+
+ IngestResult result =
template.requestBodyAndHeader("direct:capped-stream", new
ByteArrayInputStream(wav(1)),
+ Exchange.FILE_NAME, "small.wav", IngestResult.class);
+ assertThat(result.outcome()).isEqualTo(IngestResult.Outcome.INGESTED);
+ }
+
@Test
void mimeTypeComesFromTheExtensionOrTheNormalisedOption() {
// the same WAV bytes travel under every name below: only the name, or
the option, decides
diff --git
a/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducerTest.java
b/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducerTest.java
index 4d3316e19006..df44131aa81f 100644
---
a/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducerTest.java
+++
b/components/camel-ai/camel-langchain4j-ingest/src/test/java/org/apache/camel/component/langchain4j/ingest/LangChain4jIngestProducerTest.java
@@ -16,8 +16,14 @@
*/
package org.apache.camel.component.langchain4j.ingest;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -31,6 +37,7 @@ import org.apache.camel.CamelExecutionException;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -73,6 +80,9 @@ class LangChain4jIngestProducerTest extends CamelTestSupport {
+ "&documentSplitter=#bean:pipeSplitter");
from("direct:capped")
.to("langchain4j-ingest:capped?documentIdHeader=MyDocId&maxDocumentSize=20");
+ // without stream caching, which would read the whole stream
before the endpoint does
+ from("direct:capped-stream").noStreamCaching()
+
.to("langchain4j-ingest:capped-stream?documentIdHeader=MyDocId&maxDocumentSize=20");
from("direct:empty-splitter")
.to("langchain4j-ingest:empty-splitter?documentIdHeader=MyDocId"
+ "&documentSplitter=#bean:emptySplitter");
@@ -194,12 +204,68 @@ class LangChain4jIngestProducerTest extends
CamelTestSupport {
.isInstanceOf(CamelExecutionException.class)
.hasStackTraceContaining("exceeds maxDocumentSize")
.hasStackTraceContaining("characters");
+ // the cap is checked before the blank check: an oversized blank
document fails, not EMPTY
+ assertThatThrownBy(() -> template.requestBodyAndHeader("direct:capped",
+ " ".repeat(30), "MyDocId", "doc-oversized-blank"))
+ .isInstanceOf(CamelExecutionException.class)
+ .hasStackTraceContaining("exceeds maxDocumentSize (30 > 20
characters)");
IngestResult result = template.requestBodyAndHeader("direct:capped",
"twenty characters ok", "MyDocId", "doc-at-cap",
IngestResult.class);
assertThat(result.outcome()).isEqualTo(IngestResult.Outcome.INGESTED);
}
+ @Test
+ void oversizedStreamIsReadOnlyPastTheCap() {
+ // 10 MB of text is refused after one character past the cap; a blank
start does not make
+ // it EMPTY, the rest being unread
+ for (char content : new char[] { 'a', ' ' }) {
+ AtomicLong read = new AtomicLong();
+ assertThatThrownBy(() ->
template.requestBodyAndHeader("direct:capped-stream",
+ stream(10_000_000, content, read), "MyDocId",
"doc-stream"))
+ .isInstanceOf(CamelExecutionException.class)
+ .hasStackTraceContaining("exceeds maxDocumentSize (21 > 20
characters)");
+ // the reader buffers ahead, but nowhere near the whole stream
+ assertThat(read.get()).isLessThan(100_000);
+ }
+
+ IngestResult result =
template.requestBodyAndHeader("direct:capped-stream",
+ new ByteArrayInputStream("twenty characters
ok".getBytes(StandardCharsets.UTF_8)), "MyDocId",
+ "doc-stream-at-cap", IngestResult.class);
+ assertThat(result.outcome()).isEqualTo(IngestResult.Outcome.INGESTED);
+ }
+
+ /** A stream of the given number of bytes of one character, counting how
many are read. */
+ private static InputStream stream(long size, char content, AtomicLong
read) {
+ return new InputStream() {
+ @Override
+ public int read() {
+ if (read.get() >= size) {
+ return -1;
+ }
+ read.incrementAndGet();
+ return content;
+ }
+ };
+ }
+
+ @Test
+ void oversizedFileIsReadOnlyPastTheCap(@TempDir Path directory) throws
Exception {
+ // a File or a Path, as the file-watch consumer delivers, is read one
character past the
+ // cap as well: the reported size is what was read, not the whole file
+ Path big = Files.writeString(directory.resolve("big.txt"),
"a".repeat(10_000));
+ for (Object body : new Object[] { big.toFile(), big }) {
+ assertThatThrownBy(() ->
template.requestBodyAndHeader("direct:capped", body, "MyDocId", "doc-file"))
+ .isInstanceOf(CamelExecutionException.class)
+ .hasStackTraceContaining("exceeds maxDocumentSize (21 > 20
characters)");
+ }
+
+ Path atCap = Files.writeString(directory.resolve("ok.txt"), "twenty
characters ok");
+ IngestResult result = template.requestBodyAndHeader("direct:capped",
atCap.toFile(), "MyDocId",
+ "doc-file-at-cap", IngestResult.class);
+ assertThat(result.outcome()).isEqualTo(IngestResult.Outcome.INGESTED);
+ }
+
@Test
void customBatchSizeControlsTheEmbeddingRequests() {
// 10 paragraphs of ~19 characters against maxSegmentSize=30: one
segment each, and a