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