This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch TIKA-4843
in repository https://gitbox.apache.org/repos/asf/tika.git

commit 7ee24737367c8d1d74dff1402e807c15a72c85b7
Author: tallison <[email protected]>
AuthorDate: Wed Aug 26 14:04:13 2026 -0400

    TIKA-4843: fix per-request parse-context config for parsers with locked 
fields
---
 CHANGES.txt                                        | 10 +++
 .../ocr/tess4j/Tess4JRuntimeConfigMergeTest.java   | 76 ++++++++++++++++++++++
 .../apache/tika/config/loader/JsonMergeUtils.java  | 39 +++++++++--
 3 files changed, 119 insertions(+), 6 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index d7907ba138..74903fb42e 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,15 @@
 Release 4.1.0 - unreleased
 
+   * Fixed a bug that made per-request (parse-context) configuration unusable
+     for parsers that lock some config fields against caller modification --
+     Tess4J, the VLM parsers and the OpenAI image-embedding parser. Any such
+     config threw, including an empty one: the defaults were deep-copied
+     through their own setters, which the runtime config overrides to reject
+     caller input, so the copy tripped the parser's own guards before the
+     caller's JSON was read. Locked fields are still rejected when a caller
+     actually sets them. Configuration supplied at initialization time (the
+     "parsers" section) was never affected (TIKA-4843).
+
    * Documentation: corrected a batch of pages and javadocs that contradicted
      the code. Notably: the ES/OpenSearch attachmentStrategy has no default
      (unset means embedded documents get neither the parent field nor the
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
new file mode 100644
index 0000000000..5e232845dd
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.tika.parser.ocr.tess4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Locked fields must be rejected only when the caller actually sets them. The 
merge
+ * clones the default first, and that clone must not trip the guards -- 
otherwise every
+ * per-request config throws, including {@code {}}.
+ */
+public class Tess4JRuntimeConfigMergeTest {
+
+    private Tess4JConfig runtime(String json) throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("tess4j-parser", json);
+        return ParseContextConfig.getConfig(context, "tess4j-parser",
+                Tess4JConfig.RuntimeConfig.class, new 
Tess4JConfig.RuntimeConfig());
+    }
+
+    @Test
+    public void testEmptyConfigMerges() throws Exception {
+        assertEquals(new Tess4JConfig().getPoolSize(), 
runtime("{}").getPoolSize());
+    }
+
+    @Test
+    public void testUnrelatedFieldMerges() throws Exception {
+        assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
+    }
+
+    @Test
+    public void testCallerSetPoolSizeStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"poolSize\": 7}"));
+        assertTrue(rootMessage(e).contains("Cannot modify poolSize"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetMaxImagePixelsStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"maxImagePixels\": 5}"));
+        assertTrue(rootMessage(e).contains("Cannot modify maxImagePixels"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetDataPathStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"dataPath\": \"/tmp/evil\"}"));
+        assertTrue(rootMessage(e).contains("Cannot modify dataPath"), 
rootMessage(e));
+    }
+
+    private static String rootMessage(Throwable t) {
+        while (t.getCause() != null) {
+            t = t.getCause();
+        }
+        return String.valueOf(t.getMessage());
+    }
+}
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
index be00ccb064..5f99bccc76 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
@@ -17,7 +17,11 @@
 package org.apache.tika.config.loader;
 
 import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
+import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
@@ -34,6 +38,31 @@ public final class JsonMergeUtils {
         // Utility class
     }
 
+    /**
+     * Field-access mappers used only to clone an already-valid default, keyed 
by the
+     * mapper they were derived from ({@code copy()} is expensive and the set 
of source
+     * mappers is tiny and long-lived).
+     * <p>
+     * The clone must not run through setters. Runtime-config subclasses 
override their
+     * setters to reject caller input -- often as "any non-empty value is a 
modification"
+     * -- so re-applying the default's own values through them throws, and the 
caller's
+     * JSON is never even reached. Copying by field also preserves init-time 
state that
+     * has no getter (e.g. VLMOCRConfig.RuntimeConfig's initMaxTokens 
baseline), which a
+     * serialization round-trip silently reset to the class default.
+     */
+    private static final Map<ObjectMapper, ObjectMapper> COPY_MAPPERS = new 
ConcurrentHashMap<>();
+
+    private static ObjectMapper copyMapper(ObjectMapper mapper) {
+        return COPY_MAPPERS.computeIfAbsent(mapper, m -> m.copy()
+                .setVisibility(PropertyAccessor.ALL, Visibility.NONE)
+                .setVisibility(PropertyAccessor.FIELD, Visibility.ANY));
+    }
+
+    /** Clones an already-validated default without invoking its setters. */
+    private static <T> T copyDefaults(ObjectMapper mapper, Class<T> 
configClass, T defaultConfig) {
+        return copyMapper(mapper).convertValue(defaultConfig, configClass);
+    }
+
     /**
      * Deserializes JSON and merges it with a default configuration object.
      * <p>
@@ -55,10 +84,9 @@ public final class JsonMergeUtils {
             return mapper.readValue(json, configClass);
         }
 
-        // Create a deep copy of defaultConfig to preserve immutability
-        T copy = mapper.convertValue(defaultConfig, configClass);
+        T copy = copyDefaults(mapper, configClass, defaultConfig);
 
-        // Merge JSON properties into the copy
+        // Only the caller's JSON goes through setters -- that is what 
validation guards are for
         return mapper.readerForUpdating(copy).readValue(json);
     }
 
@@ -79,11 +107,10 @@ public final class JsonMergeUtils {
             return mapper.treeToValue(node, configClass);
         }
 
-        // Create a deep copy of defaultConfig to preserve immutability
         @SuppressWarnings("unchecked")
-        T copy = mapper.convertValue(defaultConfig, (Class<T>) 
defaultConfig.getClass());
+        T copy = copyDefaults(mapper, (Class<T>) defaultConfig.getClass(), 
defaultConfig);
 
-        // Merge JSON properties into the copy
+        // Only the caller's JSON goes through setters -- that is what 
validation guards are for
         return mapper.readerForUpdating(copy).readValue(node);
     }
 

Reply via email to