This is an automated email from the ASF dual-hosted git repository. acosentino pushed a commit to branch docling-backports-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 65c9234cf95b26269693ff964e6594a191420f0d Author: Andrea Cosentino <[email protected]> AuthorDate: Mon Mar 2 10:50:29 2026 +0100 CAMEL-23107 - camel-docling - Add CLI command validation in buildDoclingCommand (#21670) Signed-off-by: Andrea Cosentino <[email protected]> --- .../camel/component/docling/DoclingProducer.java | 35 +++++ .../docling/DoclingCustomArgsValidationTest.java | 165 +++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java index 38b1b7b720ea..48e68ffa2eb4 100644 --- a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java +++ b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java @@ -1603,11 +1603,46 @@ public class DoclingProducer extends DefaultProducer { @SuppressWarnings("unchecked") List<String> customArgs = exchange.getIn().getHeader(DoclingHeaders.CUSTOM_ARGUMENTS, List.class); if (customArgs != null && !customArgs.isEmpty()) { + validateCustomArguments(customArgs); LOG.debug("Adding custom Docling arguments: {}", customArgs); command.addAll(customArgs); } } + /** + * Validates custom CLI arguments to ensure they do not conflict with producer-managed options such as the output + * directory. + */ + private void validateCustomArguments(List<String> customArgs) { + // The output directory is managed by the producer via endpoint configuration + // or the OUTPUT_FILE_PATH header, so it must not be overridden through custom arguments. + List<String> blockedFlags = List.of("--output", "-o"); + + for (int i = 0; i < customArgs.size(); i++) { + String arg = customArgs.get(i); + + if (arg == null) { + throw new IllegalArgumentException("Custom argument at index " + i + " is null"); + } + + String argLower = arg.toLowerCase(); + for (String blocked : blockedFlags) { + if (argLower.equals(blocked) || argLower.startsWith(blocked + "=")) { + throw new IllegalArgumentException( + "Custom argument '" + blocked + + "' is not allowed because the output directory is managed by the producer. " + + "Use the " + DoclingHeaders.OUTPUT_FILE_PATH + + " header or endpoint configuration instead."); + } + } + + if (arg.contains("../") || arg.contains("..\\")) { + throw new IllegalArgumentException( + "Custom argument at index " + i + " contains a relative path traversal sequence"); + } + } + } + private void addOutputFormatArguments(List<String> command, String outputFormat) { if (outputFormat != null && !outputFormat.isEmpty()) { command.add("--to"); diff --git a/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingCustomArgsValidationTest.java b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingCustomArgsValidationTest.java new file mode 100644 index 000000000000..3699b5fc451f --- /dev/null +++ b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingCustomArgsValidationTest.java @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.docling; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +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.junit.jupiter.api.Assertions.*; + +/** + * Tests that custom CLI arguments passed via the {@link DoclingHeaders#CUSTOM_ARGUMENTS} header are validated to ensure + * they do not conflict with producer-managed options such as the output directory. + */ +class DoclingCustomArgsValidationTest extends CamelTestSupport { + + @TempDir + Path tempDir; + + @Test + void customArgsWithOutputFlagAreRejected() throws Exception { + Path inputFile = createInputFile(); + + // The --output flag conflicts with the producer-managed output directory + // and should be rejected before the process is started. + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.requestBodyAndHeaders("direct:cli-convert", + inputFile.toString(), + java.util.Map.of(DoclingHeaders.CUSTOM_ARGUMENTS, List.of("--output", "/tmp/other-dir"))); + }); + + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("--output")); + assertTrue(ex.getCause().getMessage().contains("not allowed")); + } + + @Test + void customArgsWithOutputEqualsFormAreRejected() throws Exception { + Path inputFile = createInputFile(); + + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.requestBodyAndHeaders("direct:cli-convert", + inputFile.toString(), + java.util.Map.of(DoclingHeaders.CUSTOM_ARGUMENTS, List.of("--output=/tmp/other-dir"))); + }); + + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("--output")); + } + + @Test + void customArgsWithShortOutputFlagAreRejected() throws Exception { + Path inputFile = createInputFile(); + + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.requestBodyAndHeaders("direct:cli-convert", + inputFile.toString(), + java.util.Map.of(DoclingHeaders.CUSTOM_ARGUMENTS, List.of("-o", "/tmp/other-dir"))); + }); + + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("-o")); + } + + @Test + void customArgsWithPathTraversalAreRejected() throws Exception { + Path inputFile = createInputFile(); + + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.requestBodyAndHeaders("direct:cli-convert", + inputFile.toString(), + java.util.Map.of(DoclingHeaders.CUSTOM_ARGUMENTS, List.of("--some-flag", "../../etc/passwd"))); + }); + + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("relative path traversal")); + } + + @Test + void customArgsWithNullEntryAreRejected() throws Exception { + Path inputFile = createInputFile(); + List<String> argsWithNull = new java.util.ArrayList<>(); + argsWithNull.add(null); + + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.requestBodyAndHeaders("direct:cli-convert", + inputFile.toString(), + java.util.Map.of(DoclingHeaders.CUSTOM_ARGUMENTS, argsWithNull)); + }); + + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("null")); + } + + @Test + void safeCustomArgsPassValidation() throws Exception { + Path inputFile = createInputFile(); + + // Safe arguments should pass validation and proceed to process execution. + // The process itself will fail (docling binary not installed in test env), + // but the error should NOT be IllegalArgumentException — that proves + // the validation passed and execution moved on to ProcessBuilder. + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.requestBodyAndHeaders("direct:cli-convert", + inputFile.toString(), + java.util.Map.of(DoclingHeaders.CUSTOM_ARGUMENTS, List.of("--verbose", "--table-mode", "fast"))); + }); + + // The failure should be from process execution, not from argument validation + assertFalse(ex.getCause() instanceof IllegalArgumentException, + "Safe custom arguments should pass validation; failure should come from process execution, not argument validation"); + } + + @Test + void noCustomArgsIsAllowed() throws Exception { + Path inputFile = createInputFile(); + + // With no custom arguments at all, validation is skipped entirely. + // The process will still fail (no docling binary), but not from validation. + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.requestBody("direct:cli-convert", inputFile.toString()); + }); + + assertFalse(ex.getCause() instanceof IllegalArgumentException, + "No custom arguments should not trigger argument validation"); + } + + private Path createInputFile() throws Exception { + Path file = tempDir.resolve("test-input.txt"); + Files.writeString(file, "test content"); + return file; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + // CLI mode (useDoclingServe=false is the default) + from("direct:cli-convert") + .to("docling:convert?operation=CONVERT_TO_MARKDOWN"); + } + }; + } +}
