This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 2d1e43ab32 TIKA-4843: fix per-request parse-context config for parsers
with lock… (#3076)
2d1e43ab32 is described below
commit 2d1e43ab32a3f3e868bb9185119a26e4c6e4cf61
Author: Tim Allison <[email protected]>
AuthorDate: Wed Aug 26 17:55:18 2026 -0400
TIKA-4843: fix per-request parse-context config for parsers with lock…
(#3076)
---
CHANGES.txt | 10 ++
.../ImageEmbeddingRuntimeConfigMergeTest.java | 78 +++++++++++
.../ocr/tess4j/Tess4JRuntimeConfigMergeTest.java | 76 ++++++++++
.../tika/parser/vlm/VLMRuntimeConfigMergeTest.java | 107 ++++++++++++++
.../apache/tika/config/loader/JsonMergeUtils.java | 39 +++++-
.../tika/config/loader/JsonMergeUtilsTest.java | 155 +++++++++++++++++++++
6 files changed, 459 insertions(+), 6 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 1ec964cf50..13bed521c1 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).
+
* OOXML parsers flag package parts that are unreachable through the OPC
relationship graph: msoffice:has-unreferenced-parts (boolean) and
msoffice:unreferenced-part-names. Purely structural (no bytes are
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
new file mode 100644
index 0000000000..9300600db6
--- /dev/null
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.inference;
+
+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;
+
+/**
+ * The non-blank {@code baseUrl} default trips this config's own "reject any
non-empty
+ * value" guard when the merge copies the defaults through the setters
(TIKA-4843).
+ */
+public class ImageEmbeddingRuntimeConfigMergeTest {
+
+ private static final String KEY = "openai-image-embedding-parser";
+
+ private ImageEmbeddingConfig runtime(String json) throws Exception {
+ ParseContext context = new ParseContext();
+ context.setJsonConfig(KEY, json);
+ return ParseContextConfig.getConfig(context, KEY,
+ ImageEmbeddingConfig.RuntimeConfig.class, new
ImageEmbeddingConfig.RuntimeConfig());
+ }
+
+ @Test
+ public void testEmptyConfigMerges() throws Exception {
+ assertEquals(new ImageEmbeddingConfig().getBaseUrl(),
runtime("{}").getBaseUrl());
+ }
+
+ @Test
+ public void testUnrelatedFieldMerges() throws Exception {
+ assertTrue(runtime("{\"skipEmbedding\": true}").isSkipEmbedding());
+ }
+
+ @Test
+ public void testCallerSetModelStillRejected() {
+ Exception e = assertThrows(Exception.class, () -> runtime("{\"model\":
\"evil\"}"));
+ assertTrue(rootMessage(e).contains("Cannot modify model"),
rootMessage(e));
+ }
+
+ @Test
+ public void testCallerSetBaseUrlStillRejected() {
+ Exception e = assertThrows(Exception.class,
+ () -> runtime("{\"baseUrl\": \"http://evil\"}"));
+ assertTrue(rootMessage(e).contains("Cannot modify baseUrl"),
rootMessage(e));
+ }
+
+ @Test
+ public void testCallerSetApiKeyStillRejected() {
+ Exception e = assertThrows(Exception.class, () ->
runtime("{\"apiKey\": \"evil\"}"));
+ assertTrue(rootMessage(e).contains("Cannot modify apiKey"),
rootMessage(e));
+ }
+
+ private static String rootMessage(Throwable t) {
+ while (t.getCause() != null) {
+ t = t.getCause();
+ }
+ return String.valueOf(t.getMessage());
+ }
+}
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-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
new file mode 100644
index 0000000000..3a84951f3a
--- /dev/null
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.vlm;
+
+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;
+
+/**
+ * VLM's locked fields must reject caller input without the merge's own copy
of the
+ * defaults tripping them first (TIKA-4843). Unlike Tess4J, several VLM
defaults are
+ * non-blank, so the "reject any non-empty value" guards fire on the copy.
+ */
+public class VLMRuntimeConfigMergeTest {
+
+ private VLMOCRConfig runtime(String json, VLMOCRConfig init) throws
Exception {
+ ParseContext context = new ParseContext();
+ context.setJsonConfig("vlm-ocr-parser", json);
+ return ParseContextConfig.getConfig(context, "vlm-ocr-parser",
+ VLMOCRConfig.RuntimeConfig.class, new
VLMOCRConfig.RuntimeConfig(init));
+ }
+
+ private VLMOCRConfig runtime(String json) throws Exception {
+ return runtime(json, new VLMOCRConfig());
+ }
+
+ @Test
+ public void testEmptyConfigMerges() throws Exception {
+ assertEquals(new VLMOCRConfig().getBaseUrl(),
runtime("{}").getBaseUrl());
+ }
+
+ @Test
+ public void testUnrelatedFieldMerges() throws Exception {
+ assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
+ }
+
+ @Test
+ public void testCallerSetModelStillRejected() {
+ Exception e = assertThrows(Exception.class, () -> runtime("{\"model\":
\"evil\"}"));
+ assertTrue(rootMessage(e).contains("Cannot modify model"),
rootMessage(e));
+ }
+
+ @Test
+ public void testCallerSetBaseUrlStillRejected() {
+ Exception e = assertThrows(Exception.class,
+ () -> runtime("{\"baseUrl\": \"http://evil\"}"));
+ assertTrue(rootMessage(e).contains("Cannot modify baseUrl"),
rootMessage(e));
+ }
+
+ @Test
+ public void testCallerSetApiKeyStillRejected() {
+ Exception e = assertThrows(Exception.class, () ->
runtime("{\"apiKey\": \"evil\"}"));
+ assertTrue(rootMessage(e).contains("Cannot modify apiKey"),
rootMessage(e));
+ }
+
+ @Test
+ public void testCallerSetAllowRuntimePromptStillRejected() {
+ Exception e = assertThrows(Exception.class,
+ () -> runtime("{\"allowRuntimePrompt\": true}"));
+ assertTrue(rootMessage(e).contains("Cannot modify
allowRuntimePrompt"), rootMessage(e));
+ }
+
+ /**
+ * The maxTokens ceiling is init-time state with no getter. If the merge's
copy resets it
+ * to the class default, a caller can raise maxTokens above what the
operator configured.
+ */
+ @Test
+ public void testMaxTokensCeilingIsNotWidenedByTheCopy() {
+ VLMOCRConfig init = new VLMOCRConfig();
+ init.setMaxTokens(100);
+ Exception e = assertThrows(Exception.class, () ->
runtime("{\"maxTokens\": 3000}", init));
+ assertTrue(rootMessage(e).contains("Cannot increase maxTokens"),
rootMessage(e));
+ }
+
+ @Test
+ public void testMaxTokensBelowCeilingStillAllowed() throws Exception {
+ VLMOCRConfig init = new VLMOCRConfig();
+ init.setMaxTokens(100);
+ assertEquals(50, runtime("{\"maxTokens\": 50}", init).getMaxTokens());
+ }
+
+ 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);
}
diff --git
a/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
new file mode 100644
index 0000000000..0bb75e8fcb
--- /dev/null
+++
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.config.loader;
+
+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 java.io.IOException;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The defensive copy of the defaults must not run through the setters.
+ * Runtime-config subclasses override setters to reject caller input -- often
as
+ * "any non-empty value is a modification" -- so re-applying the defaults' own
+ * values through them throws before the caller's JSON is ever read
(TIKA-4843).
+ */
+public class JsonMergeUtilsTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** Stands in for a parser config: a normal field plus a locked one. */
+ public static class Config {
+ private String name = "default-name";
+ private int size = 5;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getSize() {
+ return size;
+ }
+
+ public void setSize(int size) {
+ this.size = size;
+ }
+ }
+
+ /** Stands in for a RuntimeConfig: {@code size} is locked, and a ceiling
has no getter. */
+ public static class LockedConfig extends Config {
+ // no getter on purpose: init-time state a serialization round-trip
would drop
+ private int ceiling = 4096;
+
+ public LockedConfig() {
+ }
+
+ public LockedConfig(int ceiling) {
+ this.ceiling = ceiling;
+ }
+
+ public int ceiling() {
+ return ceiling;
+ }
+
+ @Override
+ public void setSize(int size) {
+ throw new IllegalStateException("Cannot modify size at runtime");
+ }
+
+ @Override
+ public void setName(String name) {
+ // the "reject any non-empty value" shape, which breaks when the
default is non-empty
+ if (name != null && !name.isEmpty()) {
+ throw new IllegalStateException("Cannot modify name at
runtime");
+ }
+ }
+ }
+
+ @Test
+ public void testEmptyJsonDoesNotTripLockedSetters() throws Exception {
+ LockedConfig merged = JsonMergeUtils.mergeWithDefaults(
+ MAPPER, "{}", LockedConfig.class, new LockedConfig());
+ assertEquals("default-name", merged.getName());
+ assertEquals(5, merged.getSize());
+ }
+
+ @Test
+ public void testNonBlankDefaultDoesNotTripRejectNonEmptySetter() throws
Exception {
+ LockedConfig defaults = new LockedConfig();
+ // the failing shape from TIKA-4843: a non-empty default re-applied
through its own guard
+ JsonMergeUtils.mergeWithDefaults(MAPPER, "{}", LockedConfig.class,
defaults);
+ }
+
+ @Test
+ public void testCallerSuppliedLockedFieldStillRejected() {
+ IOException e = assertThrows(IOException.class, () ->
JsonMergeUtils.mergeWithDefaults(
+ MAPPER, "{\"size\": 9}", LockedConfig.class, new
LockedConfig()));
+ assertTrue(rootMessage(e).contains("Cannot modify size"),
rootMessage(e));
+ }
+
+ @Test
+ public void testInitTimeStateWithoutGetterSurvivesTheCopy() throws
Exception {
+ LockedConfig merged = JsonMergeUtils.mergeWithDefaults(
+ MAPPER, "{}", LockedConfig.class, new LockedConfig(100));
+ // a serialize/deserialize round-trip would have reset this to the
class default
+ assertEquals(100, merged.ceiling());
+ }
+
+ @Test
+ public void testJsonNodeOverloadBehavesTheSame() throws Exception {
+ LockedConfig merged = JsonMergeUtils.mergeWithDefaults(
+ MAPPER, MAPPER.readTree("{}"), LockedConfig.class, new
LockedConfig(100));
+ assertEquals(100, merged.ceiling());
+ assertEquals("default-name", merged.getName());
+ }
+
+ @Test
+ public void testJsonNodeOverloadStillRejectsLockedField() {
+ assertThrows(IOException.class, () -> JsonMergeUtils.mergeWithDefaults(
+ MAPPER, MAPPER.readTree("{\"size\": 9}"), LockedConfig.class,
new LockedConfig()));
+ }
+
+ @Test
+ public void testUnlockedConfigStillMergesNormally() throws Exception {
+ Config merged = JsonMergeUtils.mergeWithDefaults(
+ MAPPER, "{\"name\": \"override\"}", Config.class, new
Config());
+ assertEquals("override", merged.getName());
+ assertEquals(5, merged.getSize(), "unspecified fields keep their
defaults");
+ }
+
+ @Test
+ public void testDefaultsObjectIsNotMutated() throws Exception {
+ Config defaults = new Config();
+ JsonMergeUtils.mergeWithDefaults(MAPPER, "{\"name\": \"override\"}",
Config.class, defaults);
+ assertEquals("default-name", defaults.getName(), "the caller's
defaults must not be touched");
+ }
+
+ private static String rootMessage(Throwable t) {
+ while (t.getCause() != null) {
+ t = t.getCause();
+ }
+ return String.valueOf(t.getMessage());
+ }
+}