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 493f5b15c2 [TIKA-4825] carry caller Content-Type across the pipes 
worker boundary as a detection hint (#3039)
493f5b15c2 is described below

commit 493f5b15c2dac7bc187cac5a247db6039fefc7ee
Author: Dominik Schmidt <[email protected]>
AuthorDate: Sat Aug 22 01:46:39 2026 +0200

    [TIKA-4825] carry caller Content-Type across the pipes worker boundary as a 
detection hint (#3039)
---
 CHANGES.txt                                        |  11 ++
 .../migration-to-4x/migrating-tika-server-4x.adoc  |   9 ++
 .../apache/tika/pipes/core/server/PipesWorker.java |  43 +++++---
 .../core/server/PipesWorkerCallerHintsTest.java    | 122 +++++++++++++++++++++
 .../apache/tika/server/core/StackTraceTest.java    |  19 +++-
 5 files changed, 190 insertions(+), 14 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 5db77dc8be..9e3a699fff 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,16 @@
 Release 4.1.0 - ???
 
+   * Pipes now carries the caller-supplied Content-Type across the worker's
+     fresh-metadata boundary as a soft detection hint, so every forked-parse
+     endpoint (/tika, /meta, /rmeta, /unpack, /async, /pipes, plus tika-grpc
+     and embedded PipesForkParser) can route on a client Content-Type, not
+     only on the filename. Detection keeps the hint only when it equals or
+     specializes the content-detected type (e.g. refining image/tiff to
+     image/x-canon-cr2); for bytes with no magic it can select any type,
+     matching the routing power the filename already had. The
+     CONTENT_TYPE_USER_OVERRIDE key is deliberately not carried, so the hint
+     cannot force an unrelated type (TIKA-4825).
+
    * RawTiffParser extracts the camera-generated JPEG previews embedded in
      TIFF-based raw images (Nikon NEF/NRW, Sony ARW/SRF/SR2, Pentax PEF/PTX,
      Adobe DNG and Canon CR2, including BigTIFF DNG containers) as thumbnail
diff --git 
a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
index 38ebe8a10d..8471e4b60a 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
@@ -428,6 +428,15 @@ Transport headers are unaffected: `Content-Disposition`,
 `Content-Type` and `Content-Length` still describe the payload and still
 influence detection.
 
+NOTE: The way `Content-Type` influences detection changed. In 3.x, parsing ran
+in-process and a request `Content-Type` acted as a hard override that forced 
the
+type. In 4.x, parsing runs in a forked worker and the header is carried across 
as
+a *soft* hint: detection keeps it only when it equals or specializes the type
+detected from the content, and otherwise ignores it (TIKA-4825). A 3.x client
+that forced an unrelated type onto arbitrary bytes (for example `text/plain`)
+will now see that type ignored in favor of content-based detection. Supply the
+correct `Content-Type` (or a filename) to refine within the detected hierarchy.
+
 === Pipes Configuration (for `/pipes` and `/async`)
 
 No pipes or fetcher configuration is required to start the server: the 
default-on endpoints
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 2ad0647544..f08f3fed3c 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
@@ -36,6 +36,7 @@ import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.UnpackHandler;
 import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
@@ -487,18 +488,8 @@ class PipesWorker implements Callable<PipesResult> {
         }
         // Use newMetadata() to apply any configured write limits
         Metadata metadata = localContext.newMetadata();
-        // Carry the caller-supplied resource name across the fresh-metadata 
boundary so
-        // detection, suffix selection, and the Frictionless manifest's name 
field see
-        // the logical filename rather than whatever the fetcher's path 
happens to be
-        // (e.g., a server-side spool prefix). TikaInputStream.get(path, 
metadata)
-        // already honors a pre-set RESOURCE_NAME_KEY.
-        Metadata tupleMetadata = fetchEmitTuple.getMetadata();
-        String suppliedName = tupleMetadata == null
-                ? null
-                : tupleMetadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
-        if (!StringUtils.isBlank(suppliedName)) {
-            metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, suppliedName);
-        }
+        // Carry the caller's resource name and Content-Type detection hints 
(see javadoc).
+        carryCallerHints(fetchEmitTuple.getMetadata(), metadata);
         FetchHandler.TisOrResult tisOrResult = 
fetchHandler.fetch(fetchEmitTuple, metadata, localContext);
         if (tisOrResult.pipesResult() != null) {
             return new ParseDataOrPipesResult(null, tisOrResult.pipesResult());
@@ -516,7 +507,33 @@ class PipesWorker implements Callable<PipesResult> {
         }
     }
 
-
+    /**
+     * Carries the caller-supplied detection hints from the tuple metadata 
across the
+     * fresh-metadata boundary into the metadata used for fetch and detection.
+     * <p>
+     * Only the resource name and the {@code Content-Type} soft hint are 
carried.
+     * {@code Content-Type} is applied by {@code MimeTypes.detect} via {@code 
applyHint},
+     * which keeps it only when it equals or specializes the magic-detected 
type (e.g.
+     * {@code image/tiff} -&gt; {@code image/x-raw-nikon} for a NEF supplied 
without a
+     * filename). The {@code CONTENT_TYPE_USER_OVERRIDE} key is deliberately 
NOT carried:
+     * it short-circuits detection unconditionally and would let a caller 
force any type.
+     *
+     * @param tupleMetadata the caller-supplied metadata (may be null)
+     * @param target the fresh metadata used for fetch and detection
+     */
+    static void carryCallerHints(Metadata tupleMetadata, Metadata target) {
+        if (tupleMetadata == null) {
+            return;
+        }
+        String suppliedName = 
tupleMetadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
+        if (!StringUtils.isBlank(suppliedName)) {
+            target.set(TikaCoreProperties.RESOURCE_NAME_KEY, suppliedName);
+        }
+        String suppliedContentType = 
tupleMetadata.get(HttpHeaders.CONTENT_TYPE);
+        if (!StringUtils.isBlank(suppliedContentType)) {
+            target.set(HttpHeaders.CONTENT_TYPE, suppliedContentType);
+        }
+    }
 
     private ParseContext setupParseContext() throws TikaException, IOException 
{
         // ContentHandlerFactory and ParseMode are retrieved from ParseContext 
in ParseHandler.
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PipesWorkerCallerHintsTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PipesWorkerCallerHintsTest.java
new file mode 100644
index 0000000000..e5c5c14784
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PipesWorkerCallerHintsTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.pipes.core.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.mime.MimeTypes;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Unit tests for {@link PipesWorker#carryCallerHints(Metadata, Metadata)}, 
which carries the
+ * caller-supplied detection hints across the worker's fresh-metadata boundary.
+ */
+public class PipesWorkerCallerHintsTest {
+
+    @Test
+    public void testCarriesResourceNameAndContentType() {
+        Metadata tuple = new Metadata();
+        tuple.set(TikaCoreProperties.RESOURCE_NAME_KEY, "photo.nef");
+        tuple.set(HttpHeaders.CONTENT_TYPE, "image/x-raw-nikon");
+
+        Metadata target = new Metadata();
+        PipesWorker.carryCallerHints(tuple, target);
+
+        assertEquals("photo.nef", 
target.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("image/x-raw-nikon", 
target.get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    /**
+     * The Content-Type is carried only as a soft hint. The unconditional 
override keys
+     * must never be carried, or a caller could force any type past detection.
+     */
+    @Test
+    public void testDoesNotCarryOverrides() {
+        Metadata tuple = new Metadata();
+        tuple.set(TikaCoreProperties.CONTENT_TYPE_USER_OVERRIDE, 
"image/x-raw-nikon");
+        tuple.set(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE, 
"image/x-raw-nikon");
+
+        Metadata target = new Metadata();
+        PipesWorker.carryCallerHints(tuple, target);
+
+        assertNull(target.get(TikaCoreProperties.CONTENT_TYPE_USER_OVERRIDE));
+        
assertNull(target.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE));
+        assertNull(target.get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testNullTupleIsNoOp() {
+        Metadata target = new Metadata();
+        target.set(TikaCoreProperties.RESOURCE_NAME_KEY, "keep.me");
+        PipesWorker.carryCallerHints(null, target);
+        assertEquals("keep.me", 
target.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+    }
+
+    @Test
+    public void testBlankValuesNotCarried() {
+        Metadata tuple = new Metadata();
+        tuple.set(TikaCoreProperties.RESOURCE_NAME_KEY, "   ");
+        tuple.set(HttpHeaders.CONTENT_TYPE, "");
+
+        Metadata target = new Metadata();
+        PipesWorker.carryCallerHints(tuple, target);
+
+        assertNull(target.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertNull(target.get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    //content that magic-detects as image/tiff (little-endian TIFF marker, no 
CR2 marker)
+    private static final byte[] TIFF_BYTES = {'I', 'I', 0x2A, 0x00, 0, 0, 0, 
8};
+
+    private static MediaType detectWithCarriedContentType(String contentType) 
throws Exception {
+        Metadata tuple = new Metadata();
+        tuple.set(HttpHeaders.CONTENT_TYPE, contentType);
+        Metadata target = new Metadata();
+        PipesWorker.carryCallerHints(tuple, target);
+        try (TikaInputStream tis = TikaInputStream.get(TIFF_BYTES)) {
+            return MimeTypes.getDefaultMimeTypes().detect(tis, target, new 
ParseContext());
+        }
+    }
+
+    /**
+     * A carried Content-Type that specializes the content-detected type 
refines detection.
+     * image/x-canon-cr2 is a sub-class-of image/tiff, and these bytes lack 
the CR2 marker.
+     */
+    @Test
+    public void testSpecializingContentTypeRefinesDetection() throws Exception 
{
+        assertEquals(MediaType.image("x-canon-cr2"),
+                detectWithCarriedContentType("image/x-canon-cr2"));
+    }
+
+    /**
+     * Security boundary: a carried Content-Type that does NOT specialize the 
content-detected
+     * type is ignored, so a caller cannot force an unrelated type onto the 
document.
+     */
+    @Test
+    public void testNonSpecializingContentTypeIgnored() throws Exception {
+        assertEquals(MediaType.image("tiff"), 
detectWithCarriedContentType("audio/mpeg"));
+        assertEquals(MediaType.image("tiff"), 
detectWithCarriedContentType("not-a-media-type"));
+    }
+}
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/StackTraceTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/StackTraceTest.java
index b14f2f60e4..c0e5b8226b 100644
--- 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/StackTraceTest.java
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/StackTraceTest.java
@@ -191,7 +191,11 @@ public class StackTraceTest extends CXFTestBase {
     }
 
 
-    // A truncated document isn't a process failure -- NOT_FOUND, not 
BAD_REQUEST.
+    // Since TIKA-4825 the caller-supplied Content-Type is carried into 
detection, so an
+    // explicit application/mock+xml routes the (truncated) document to the 
mock parser,
+    // which cannot parse the incomplete XML. That container exception maps to 
422 for the
+    // bare-field endpoint (which has no envelope to embed it in). Without a 
Content-Type the
+    // truncated bytes detect as generic XML and still yield NOT_FOUND -- see 
testMetaNoType.
     @Test
     public void testMeta() throws Exception {
         InputStream stream = 
ClassLoader.getSystemResourceAsStream(TEST_HELLO_WORLD);
@@ -201,6 +205,19 @@ public class StackTraceTest extends CXFTestBase {
                 .type("application/mock+xml")
                 .accept(MediaType.TEXT_PLAIN)
                 .put(copy(stream, 100));
+        assertEquals(422, response.getStatus());
+    }
+
+    // A truncated document with no forcing Content-Type isn't a process 
failure --
+    // NOT_FOUND (field missing), not BAD_REQUEST.
+    @Test
+    public void testMetaNoType() throws Exception {
+        InputStream stream = 
ClassLoader.getSystemResourceAsStream(TEST_HELLO_WORLD);
+
+        Response response = WebClient
+                .create(endPoint + "/meta" + "/Author")
+                .accept(MediaType.TEXT_PLAIN)
+                .put(copy(stream, 100));
         assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
         String msg = getStringFromInputStream((InputStream) 
response.getEntity());
         assertEquals("Failed to get metadata field Author", msg);

Reply via email to