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 3d4262da49 TIKA-4897: serialize unpaired surrogates in metadata as
U+FFFD (#3184)
3d4262da49 is described below
commit 3d4262da49b0edcd871d59fb36169eb339799e2c
Author: Tim Allison <[email protected]>
AuthorDate: Wed Sep 16 20:35:13 2026 -0400
TIKA-4897: serialize unpaired surrogates in metadata as U+FFFD (#3184)
---
CHANGES.txt | 9 ++
.../java/org/apache/tika/metadata/Metadata.java | 20 +++-
.../java/org/apache/tika/utils/StringUtils.java | 53 +++++++++
.../tika/metadata/MetadataWellFormedTest.java | 96 ++++++++++++++++
.../core/serialization/PipesResultSerializer.java | 3 +-
.../apache/tika/pipes/core/server/PipesWorker.java | 24 ++--
.../tika/pipes/core/server/ServerProtocolIO.java | 47 +++++++-
.../pipes/core/server/ServerProtocolIOTest.java | 127 +++++++++++++++++++++
.../serialization/serdes/MetadataSerializer.java | 1 +
.../serdes/MetadataSerializerSurrogateTest.java | 58 ++++++++++
10 files changed, 416 insertions(+), 22 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 4a41f7d0a1..05fe851477 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,14 @@
Release 4.1.0 - unreleased
+ * Metadata never stores an unpaired UTF-16 surrogate: every write replaces
+ one with U+FFFD (StringUtils.wellFormed), so no reader meets one; the
+ Smile encoder on the pipes channel rejected them and the parent counted
+ a worker crash. tk:content is exempt, since SafeContentHandler already
+ guarantees it. A result the worker cannot encode is reported for its
+ document with its own status (a fetch failure stays a fetch failure)
+ instead of counting as a crash, and that report cannot itself overflow
+ the payload limit (TIKA-4897).
+
* openai-embedding-engine gains "requestParameters" to enable custom
parameters per vendor (TIKA-4896).
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Metadata.java
b/tika-core/src/main/java/org/apache/tika/metadata/Metadata.java
index d5a9467dbf..f995e42fea 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/Metadata.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Metadata.java
@@ -41,6 +41,7 @@ import
org.apache.tika.metadata.writelimiter.MetadataWriteLimiter;
import org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.utils.DateUtils;
+import org.apache.tika.utils.StringUtils;
/**
* A multi-valued metadata container.
@@ -445,7 +446,18 @@ public class Metadata implements Serializable {
* reserved key by name rather than by its {@link Property}.
*/
public void addTrusted(final String name, final String value) {
- writeLimiter.add(name, value, metadata);
+ writeLimiter.add(name, wellFormed(name, value), metadata);
+ }
+
+ /**
+ * Every string value is stored well-formed: a lone surrogate becomes
U+FFFD at the write,
+ * so no reader (a Smile encoder, for one) meets one. {@code tk:content}
is exempt: it came
+ * through {@link org.apache.tika.sax.SafeContentHandler}, which already
did this, and it
+ * is the one value large enough for the scan to cost anything.
+ */
+ private static String wellFormed(String name, String value) {
+ return TikaCoreProperties.TIKA_CONTENT.getName().equals(name) ? value
+ : StringUtils.wellFormed(value);
}
/**
@@ -481,7 +493,9 @@ public class Metadata implements Serializable {
name.length(), MAX_PREFIX_ROUTE_NAME_LENGTH);
return;
}
- String key = prefix.key(name);
+ // a source-derived name can carry a lone surrogate as easily as a
value; a code
+ // constant never does, so only this route pays for the check on the
name
+ String key = prefix.key(StringUtils.wellFormed(name));
if (!metadata.containsKey(key)) {
if (prefixRouteNames >= MAX_PREFIX_ROUTE_NAMES) {
if (!prefixRouteFloodWarned) {
@@ -680,7 +694,7 @@ public class Metadata implements Serializable {
* ({@code tk:}) keys directly. See {@link #addTrusted}.
*/
public void setTrusted(String name, String value) {
- writeLimiter.set(name, value, metadata);
+ writeLimiter.set(name, wellFormed(name, value), metadata);
}
protected void set(String name, String[] values) {
diff --git a/tika-core/src/main/java/org/apache/tika/utils/StringUtils.java
b/tika-core/src/main/java/org/apache/tika/utils/StringUtils.java
index b09963d461..0a37e1a79f 100644
--- a/tika-core/src/main/java/org/apache/tika/utils/StringUtils.java
+++ b/tika-core/src/main/java/org/apache/tika/utils/StringUtils.java
@@ -221,4 +221,57 @@ public class StringUtils {
}
return sb.toString();
}
+
+ /**
+ * The value with every unpaired UTF-16 surrogate replaced by U+FFFD. A
parser can hand back
+ * a lone surrogate (an HTML numeric character reference for one, say); a
UTF-8 or Smile
+ * encoder rejects it, and one bad value must not fail a whole document.
Returns the same
+ * instance when nothing needs replacing.
+ */
+ public static String wellFormed(String value) {
+ if (value == null) {
+ return null;
+ }
+ int n = value.length();
+ StringBuilder out = null;
+ for (int i = 0; i < n; i++) {
+ char c = value.charAt(i);
+ if (!Character.isSurrogate(c)) {
+ if (out != null) {
+ out.append(c);
+ }
+ continue;
+ }
+ boolean paired = Character.isHighSurrogate(c) && i + 1 < n
+ && Character.isLowSurrogate(value.charAt(i + 1));
+ if (paired) {
+ if (out != null) {
+ out.append(c).append(value.charAt(i + 1));
+ }
+ i++;
+ continue;
+ }
+ if (out == null) {
+ out = new StringBuilder(n);
+ out.append(value, 0, i);
+ }
+ out.append('\uFFFD');
+ }
+ return out == null ? value : out.toString();
+ }
+
+ /** {@link #wellFormed(String)} over an array: the same array when nothing
needs replacing. */
+ public static String[] wellFormed(String[] values) {
+ String[] out = null;
+ for (int i = 0; i < values.length; i++) {
+ String w = wellFormed(values[i]);
+ if (w != values[i]) {
+ if (out == null) {
+ out = values.clone();
+ }
+ out[i] = w;
+ }
+ }
+ return out == null ? values : out;
+ }
}
diff --git
a/tika-core/src/test/java/org/apache/tika/metadata/MetadataWellFormedTest.java
b/tika-core/src/test/java/org/apache/tika/metadata/MetadataWellFormedTest.java
new file mode 100644
index 0000000000..e5cf0dbf10
--- /dev/null
+++
b/tika-core/src/test/java/org/apache/tika/metadata/MetadataWellFormedTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.metadata;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.writelimiter.StandardMetadataLimiterFactory;
+import org.apache.tika.utils.StringUtils;
+
+/**
+ * A lone UTF-16 surrogate never gets stored: every write replaces it with
U+FFFD, so no
+ * reader (a Smile encoder, for one) meets one. tk:content is exempt, since it
came through
+ * SafeContentHandler, which already did this.
+ */
+public class MetadataWellFormedTest {
+
+ private static final String LONE_HIGH = "abc\uDB2Cdef";
+ private static final String LONE_LOW = "\uDC00abc";
+ private static final String PAIR = "a😀b";
+
+ @Test
+ public void testWellFormed() {
+ assertEquals("abc�def", StringUtils.wellFormed(LONE_HIGH));
+ assertEquals("�abc", StringUtils.wellFormed(LONE_LOW));
+ assertEquals("a��b", StringUtils.wellFormed("a\uDB2C\uDB2Cb"));
+ assertEquals("�", StringUtils.wellFormed("\uDB2C"));
+ assertEquals("", StringUtils.wellFormed(""));
+ // a well-formed value comes back as is, not copied; so does an array
of them
+ assertSame(PAIR, StringUtils.wellFormed(PAIR));
+ String[] clean = {"plain", PAIR};
+ assertSame(clean, StringUtils.wellFormed(clean));
+ String[] dirty = {"plain", LONE_HIGH};
+ String[] fixed = StringUtils.wellFormed(dirty);
+ assertArrayEquals(new String[]{"plain", "abc�def"}, fixed);
+ assertEquals(LONE_HIGH, dirty[1], "the input array is not rewritten");
+ }
+
+ @Test
+ public void testEveryWriteStoresWellFormed() {
+ for (Metadata metadata : new Metadata[]{new Metadata(),
+ new Metadata(new
StandardMetadataLimiterFactory().newInstance())}) {
+ metadata.set("single", LONE_HIGH);
+ metadata.add("multi", LONE_LOW);
+ metadata.add("multi", PAIR);
+ metadata.set(TikaCoreProperties.TITLE, LONE_HIGH);
+ metadata.setTrusted("tk:trusted", LONE_LOW);
+ metadata.addTrusted("tk:trusted", LONE_HIGH);
+ metadata.set(TikaCoreProperties.SUBJECT, new String[]{LONE_LOW,
PAIR});
+
+ assertEquals("abc�def", metadata.get("single"));
+ assertArrayEquals(new String[]{"�abc", PAIR},
metadata.getValues("multi"));
+ assertEquals("abc�def", metadata.get(TikaCoreProperties.TITLE));
+ assertArrayEquals(new String[]{"�abc", "abc�def"},
+ metadata.getValues("tk:trusted"));
+ assertArrayEquals(new String[]{"�abc", PAIR},
+ metadata.getValues(TikaCoreProperties.SUBJECT));
+ }
+ }
+
+ /** A source-derived name on the prefix route is sanitized like a value;
the serializer writes it as a field name. */
+ @Test
+ public void testPrefixRouteNameIsWellFormed() {
+ Metadata metadata = new Metadata();
+ metadata.add(Office.USER_DEFINED, "prop" + LONE_HIGH, "v");
+ String[] names = metadata.names();
+ assertEquals(1, names.length);
+ assertEquals(Office.USER_DEFINED.key("propabc\uFFFDdef"), names[0]);
+ assertEquals("v", metadata.get(names[0]));
+ }
+
+ /** tk:content is SafeContentHandler's job and the one value big enough
for the scan to matter. */
+ @Test
+ public void testContentIsExempt() {
+ Metadata metadata = new Metadata();
+ metadata.add(TikaCoreProperties.TIKA_CONTENT, LONE_HIGH);
+ assertEquals(LONE_HIGH, metadata.get(TikaCoreProperties.TIKA_CONTENT));
+ }
+}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesResultSerializer.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesResultSerializer.java
index ebf01a5a47..b735c47bda 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesResultSerializer.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesResultSerializer.java
@@ -39,7 +39,8 @@ public class PipesResultSerializer extends
JsonSerializer<PipesResult> {
jsonGenerator.writeObjectField(EMIT_DATA, pipesResult.emitData());
}
if (!StringUtils.isBlank(pipesResult.message())) {
- jsonGenerator.writeStringField(MESSAGE, pipesResult.message());
+ // an exception message may quote document text; Smile rejects a
lone surrogate
+ jsonGenerator.writeStringField(MESSAGE,
StringUtils.wellFormed(pipesResult.message()));
}
jsonGenerator.writeEndObject();
}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesWorker.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesWorker.java
index 331d70d74e..54122a811e 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesWorker.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesWorker.java
@@ -440,16 +440,7 @@ class PipesWorker implements Callable<PipesResult> {
List<java.util.Map<String, Object>> metadataMapList = new
java.util.ArrayList<>();
for (Metadata metadata : metadataList) {
- java.util.Map<String, Object> metadataMap = new
java.util.LinkedHashMap<>();
- for (String name : metadata.names()) {
- String[] values = metadata.getValues(name);
- if (values.length == 1) {
- metadataMap.put(name, values[0]);
- } else {
- metadataMap.put(name, values);
- }
- }
- metadataMapList.add(metadataMap);
+ metadataMapList.add(metadataMap(metadata));
}
mapper.writeValue(os, metadataMapList);
}
@@ -498,17 +489,16 @@ class PipesWorker implements Callable<PipesResult> {
ObjectMapper mapper = new ObjectMapper();
// Disable auto-close so we don't close the zip output stream
mapper.configure(com.fasterxml.jackson.core.JsonGenerator.Feature.AUTO_CLOSE_TARGET,
false);
- // Convert metadata to a map for JSON serialization
+ mapper.writeValue(os, metadataMap(metadata));
+ }
+
+ private static java.util.Map<String, Object> metadataMap(Metadata
metadata) {
java.util.Map<String, Object> metadataMap = new
java.util.LinkedHashMap<>();
for (String name : metadata.names()) {
String[] values = metadata.getValues(name);
- if (values.length == 1) {
- metadataMap.put(name, values[0]);
- } else {
- metadataMap.put(name, values);
- }
+ metadataMap.put(name, values.length == 1 ? values[0] : values);
}
- mapper.writeValue(os, metadataMap);
+ return metadataMap;
}
protected ParseDataOrPipesResult parseFromTuple() throws TikaException,
InterruptedException {
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
index 1e8eb30ee6..dc1a60b108 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
@@ -23,6 +23,7 @@ import java.io.IOException;
import java.io.OutputStream;
import java.util.Locale;
+import com.fasterxml.jackson.core.JsonProcessingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -128,7 +129,16 @@ public class ServerProtocolIO {
} catch (IOException e) {
lastRespSerNanos = System.nanoTime() - serStart;
if (!bos.overflowed()) {
- throw e;
+ if (!(e instanceof JsonProcessingException)) {
+ throw e;
+ }
+ // the result itself cannot be encoded (the buffer is in
memory, so this is
+ // not the pipe): report it for this document rather than let
the parent
+ // count a worker crash and lose the document
+ LOG.warn("result could not be serialized; returning a
status-only result", e);
+ writeStatusOnly(unserializableStatus(pipesResult.status()),
+ "result could not be serialized: " + e.getMessage());
+ return;
}
LOG.warn("Payload exceeded maxIpcPayloadBytes {}; returning
PAYLOAD_LIMIT_EXCEEDED",
maxIpcPayloadBytes);
@@ -204,6 +214,41 @@ public class ServerProtocolIO {
lastIntermediateNanos = -1;
}
+ /**
+ * A success whose payload is lost is a parse failure for the parent; an
emitted status
+ * stays so the parent does not emit again, and a failure keeps its own
status and category.
+ */
+ static PipesResult.RESULT_STATUS
unserializableStatus(PipesResult.RESULT_STATUS status) {
+ if (alreadyEmitted(status) || status.getCategory() !=
PipesResult.CATEGORY.SUCCESS) {
+ return status;
+ }
+ return PipesResult.RESULT_STATUS.PARSE_EXCEPTION_NO_EMIT;
+ }
+
+ /**
+ * A status and message; the same status with a short fixed message when
that overflows
+ * the limit (the status is what the parent's retry and reporting need);
the guaranteed-fit
+ * frame when even that does not fit.
+ */
+ private void writeStatusOnly(PipesResult.RESULT_STATUS status, String
message)
+ throws IOException {
+ for (String m : new String[]{message, "result could not be
serialized"}) {
+ BoundedOutputStream fallbackBos = new
BoundedOutputStream(maxIpcPayloadBytes);
+ try {
+ JsonPipesIpc.toStream(new PipesResult(status, m), fallbackBos);
+ } catch (IOException e) {
+ if (!fallbackBos.overflowed()) {
+ throw e;
+ }
+ continue;
+ }
+ PipesMessage.finished(fallbackBos.toByteArray()).write(output);
+ awaitAck();
+ return;
+ }
+ doWritePayloadLimitExceeded();
+ }
+
/**
* True for statuses whose content the server already emitted. Replacing
one of these
* with a failure status makes the client treat an emitted document as
failed, so a
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
index 2b91a2afb0..41458feffa 100644
---
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
@@ -33,6 +33,7 @@ import org.junit.jupiter.api.Test;
import org.apache.tika.config.ExceptionReporting;
import org.apache.tika.config.TimeoutLimits;
import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.pipes.api.PipesResult;
import org.apache.tika.pipes.core.emitter.EmitDataImpl;
@@ -192,6 +193,132 @@ class ServerProtocolIOTest {
assertEquals(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
returned.status());
}
+ /**
+ * A lone surrogate in a metadata value (an HTML numeric character
reference in a title,
+ * say) used to fail the Smile encoding of the whole result, which the
server treated as
+ * a crash; Metadata stores U+FFFD in its place, so the value crosses the
pipe. Extracted
+ * text is SafeContentHandler's job and never carries one.
+ */
+ @Test
+ void testLoneSurrogateInMetadataCrossesThePipe() throws Exception {
+ Metadata m = new Metadata();
+ m.set(TikaCoreProperties.TITLE, "Korean : \uDB2C\u0620 : more");
+ PipesResult result = new
PipesResult(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
+ new EmitDataImpl("key", List.of(m)));
+
+ PipesResult returned = exchange(result,
PipesMessage.MAX_PAYLOAD_BYTES);
+
+ assertEquals(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
returned.status());
+ assertEquals("Korean : \uFFFD\u0620 : more",
+
returned.emitData().getMetadataList().get(0).get(TikaCoreProperties.TITLE));
+ }
+
+ /**
+ * A result that cannot be encoded at all is reported for its document,
not as a worker
+ * crash: the parent gets a status-only result naming the failure, and an
already
+ * emitted status is kept so the parent does not emit the document again.
+ */
+ @Test
+ void testUnserializableResultBecomesAStatusOnlyResult() throws Exception {
+ Metadata poison = new Metadata() {
+ @Override
+ public String[] getValues(String name) {
+ throw new IllegalStateException("cannot read this value");
+ }
+ };
+ poison.set("k", "v");
+
+ PipesResult notEmitted = new
PipesResult(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
+ new EmitDataImpl("key", List.of(poison)));
+ PipesResult returned = exchange(notEmitted,
PipesMessage.MAX_PAYLOAD_BYTES);
+ assertEquals(PipesResult.RESULT_STATUS.PARSE_EXCEPTION_NO_EMIT,
returned.status());
+ assertTrue(returned.message().contains("could not be serialized"),
returned.message());
+ assertTrue(returned.message().contains("cannot read this value"),
returned.message());
+
+ PipesResult emitted = new
PipesResult(PipesResult.RESULT_STATUS.EMIT_SUCCESS,
+ new EmitDataImpl("key", List.of(poison)));
+ returned = exchange(emitted, PipesMessage.MAX_PAYLOAD_BYTES);
+ assertEquals(PipesResult.RESULT_STATUS.EMIT_SUCCESS,
returned.status());
+ }
+
+ /**
+ * A failure whose payload cannot be encoded keeps its status and
category: the parent
+ * treats a fetch failure as a fetch failure, not as a parse it can report
on.
+ */
+ @Test
+ void testUnserializableFailureKeepsItsStatus() throws Exception {
+ Metadata poison = new Metadata() {
+ @Override
+ public String[] getValues(String name) {
+ throw new IllegalStateException("cannot read this value");
+ }
+ };
+ poison.set("k", "v");
+ PipesResult fetchFailed = new
PipesResult(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
+ new EmitDataImpl("key", List.of(poison)), "fetch failed");
+
+ PipesResult returned = exchange(fetchFailed,
PipesMessage.MAX_PAYLOAD_BYTES);
+
+ assertEquals(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
returned.status());
+ assertTrue(returned.message().contains("could not be serialized"),
returned.message());
+ }
+
+ /** The category rule behind the fallback, over every status. */
+ @Test
+ void testUnserializableStatusRule() {
+ for (PipesResult.RESULT_STATUS status :
PipesResult.RESULT_STATUS.values()) {
+ PipesResult.RESULT_STATUS fallback =
ServerProtocolIO.unserializableStatus(status);
+ boolean emitted = status == PipesResult.RESULT_STATUS.EMIT_SUCCESS
+ || status ==
PipesResult.RESULT_STATUS.EMIT_SUCCESS_PASSBACK
+ || status ==
PipesResult.RESULT_STATUS.EMIT_SUCCESS_PARSE_EXCEPTION;
+ if (emitted || status.getCategory() !=
PipesResult.CATEGORY.SUCCESS) {
+ assertEquals(status, fallback, status.name());
+ } else {
+
assertEquals(PipesResult.RESULT_STATUS.PARSE_EXCEPTION_NO_EMIT, fallback,
status.name());
+ }
+ }
+ }
+
+ /**
+ * When the status-only report does not fit the limit, the status is kept
with a short
+ * fixed message (a fetch failure stays a fetch failure); only when even
that does not
+ * fit does the guaranteed-fit frame go out, never an exception the parent
counts as a crash.
+ */
+ @Test
+ void testUnserializableFallbackThatOverflowsKeepsTheStatus() throws
Exception {
+ String longMessage = "x".repeat(4096);
+ Metadata poison = new Metadata() {
+ @Override
+ public String[] getValues(String name) {
+ throw new IllegalStateException(longMessage);
+ }
+ };
+ poison.set("k", "v");
+ PipesResult fetchFailed = new
PipesResult(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
+ new EmitDataImpl("key", List.of(poison)), "fetch failed");
+
+ PipesResult returned = exchange(fetchFailed, 256);
+ assertEquals(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
returned.status(),
+ "the long message is dropped, the status is not");
+ assertEquals("result could not be serialized", returned.message());
+
+ returned = exchange(fetchFailed,
ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES);
+ assertEquals(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
returned.status(),
+ "nothing but the static frame fits the minimum limit");
+ }
+
+ /** A lone surrogate in an exception message crosses the Smile channel as
U+FFFD. */
+ @Test
+ void testLoneSurrogateInMessageCrossesThePipe() throws Exception {
+ PipesResult result = new
PipesResult(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
+ "no such file: \uD800.pdf");
+
+ PipesResult returned = exchange(result,
PipesMessage.MAX_PAYLOAD_BYTES);
+
+ assertEquals(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
returned.status());
+ assertEquals("no such file: \uFFFD.pdf", returned.message());
+ }
+
/**
* Status-only results (no emitData) always pass through unchanged.
*/
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
index 0dd0607159..db67f6e51a 100644
---
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
@@ -73,4 +73,5 @@ public class MetadataSerializer extends
JsonSerializer<Metadata> {
}
jsonGenerator.writeEndObject();
}
+
}
diff --git
a/tika-serialization/src/test/java/org/apache/tika/serialization/serdes/MetadataSerializerSurrogateTest.java
b/tika-serialization/src/test/java/org/apache/tika/serialization/serdes/MetadataSerializerSurrogateTest.java
new file mode 100644
index 0000000000..d5e3ef6cbf
--- /dev/null
+++
b/tika-serialization/src/test/java/org/apache/tika/serialization/serdes/MetadataSerializerSurrogateTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.serialization.serdes;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.smile.SmileFactory;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.config.loader.TikaObjectMapperFactory;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * A parser can hand back a lone surrogate (an HTML numeric character
reference for one, for
+ * instance). The UTF-8 and Smile generators reject it, and before this a
single such value
+ * failed the whole document's serialization, which in the pipes worker was
fatal.
+ */
+public class MetadataSerializerSurrogateTest {
+
+ private static final String LONE_HIGH = "abc\uDB2Cdef";
+ private static final String LONE_LOW = "\uDC00abc";
+ private static final String PAIR = "a😀b";
+
+ @Test
+ public void testLoneSurrogatesSurviveUtf8AndSmile() throws Exception {
+ Metadata metadata = new Metadata();
+ metadata.set("single", LONE_HIGH);
+ metadata.add("multi", LONE_LOW);
+ metadata.add("multi", PAIR);
+ metadata.set("pair", PAIR);
+
+ for (ObjectMapper mapper : new ObjectMapper[] {
+ TikaObjectMapperFactory.createMapper(),
+ TikaObjectMapperFactory.createMapper(new SmileFactory())}) {
+ byte[] bytes = mapper.writeValueAsBytes(metadata);
+ Metadata back = mapper.readValue(bytes, Metadata.class);
+ assertEquals("abc\uFFFDdef", back.get("single"));
+ assertArrayEquals(new String[] {"\uFFFDabc", PAIR},
back.getValues("multi"));
+ assertEquals(PAIR, back.get("pair"));
+ }
+ }
+}