Skip to content

Commit 493f5b1

Browse files
authored
[TIKA-4825] carry caller Content-Type across the pipes worker boundary as a detection hint (#3039)
1 parent a9abca0 commit 493f5b1

5 files changed

Lines changed: 190 additions & 14 deletions

File tree

CHANGES.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
Release 4.1.0 - ???
22

3+
* Pipes now carries the caller-supplied Content-Type across the worker's
4+
fresh-metadata boundary as a soft detection hint, so every forked-parse
5+
endpoint (/tika, /meta, /rmeta, /unpack, /async, /pipes, plus tika-grpc
6+
and embedded PipesForkParser) can route on a client Content-Type, not
7+
only on the filename. Detection keeps the hint only when it equals or
8+
specializes the content-detected type (e.g. refining image/tiff to
9+
image/x-canon-cr2); for bytes with no magic it can select any type,
10+
matching the routing power the filename already had. The
11+
CONTENT_TYPE_USER_OVERRIDE key is deliberately not carried, so the hint
12+
cannot force an unrelated type (TIKA-4825).
13+
314
* RawTiffParser extracts the camera-generated JPEG previews embedded in
415
TIFF-based raw images (Nikon NEF/NRW, Sony ARW/SRF/SR2, Pentax PEF/PTX,
516
Adobe DNG and Canon CR2, including BigTIFF DNG containers) as thumbnail

docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,15 @@ Transport headers are unaffected: `Content-Disposition`,
428428
`Content-Type` and `Content-Length` still describe the payload and still
429429
influence detection.
430430
431+
NOTE: The way `Content-Type` influences detection changed. In 3.x, parsing ran
432+
in-process and a request `Content-Type` acted as a hard override that forced the
433+
type. In 4.x, parsing runs in a forked worker and the header is carried across as
434+
a *soft* hint: detection keeps it only when it equals or specializes the type
435+
detected from the content, and otherwise ignores it (TIKA-4825). A 3.x client
436+
that forced an unrelated type onto arbitrary bytes (for example `text/plain`)
437+
will now see that type ignored in favor of content-based detection. Supply the
438+
correct `Content-Type` (or a filename) to refine within the detected hierarchy.
439+
431440
=== Pipes Configuration (for `/pipes` and `/async`)
432441
433442
No pipes or fetcher configuration is required to start the server: the default-on endpoints

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesWorker.java

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
3737
import org.apache.tika.extractor.UnpackHandler;
3838
import org.apache.tika.io.TikaInputStream;
39+
import org.apache.tika.metadata.HttpHeaders;
3940
import org.apache.tika.metadata.Metadata;
4041
import org.apache.tika.metadata.TikaCoreProperties;
4142
import org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
@@ -487,18 +488,8 @@ protected ParseDataOrPipesResult parseFromTuple() throws TikaException, Interrup
487488
}
488489
// Use newMetadata() to apply any configured write limits
489490
Metadata metadata = localContext.newMetadata();
490-
// Carry the caller-supplied resource name across the fresh-metadata boundary so
491-
// detection, suffix selection, and the Frictionless manifest's name field see
492-
// the logical filename rather than whatever the fetcher's path happens to be
493-
// (e.g., a server-side spool prefix). TikaInputStream.get(path, metadata)
494-
// already honors a pre-set RESOURCE_NAME_KEY.
495-
Metadata tupleMetadata = fetchEmitTuple.getMetadata();
496-
String suppliedName = tupleMetadata == null
497-
? null
498-
: tupleMetadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
499-
if (!StringUtils.isBlank(suppliedName)) {
500-
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, suppliedName);
501-
}
491+
// Carry the caller's resource name and Content-Type detection hints (see javadoc).
492+
carryCallerHints(fetchEmitTuple.getMetadata(), metadata);
502493
FetchHandler.TisOrResult tisOrResult = fetchHandler.fetch(fetchEmitTuple, metadata, localContext);
503494
if (tisOrResult.pipesResult() != null) {
504495
return new ParseDataOrPipesResult(null, tisOrResult.pipesResult());
@@ -516,7 +507,33 @@ protected ParseDataOrPipesResult parseFromTuple() throws TikaException, Interrup
516507
}
517508
}
518509

519-
510+
/**
511+
* Carries the caller-supplied detection hints from the tuple metadata across the
512+
* fresh-metadata boundary into the metadata used for fetch and detection.
513+
* <p>
514+
* Only the resource name and the {@code Content-Type} soft hint are carried.
515+
* {@code Content-Type} is applied by {@code MimeTypes.detect} via {@code applyHint},
516+
* which keeps it only when it equals or specializes the magic-detected type (e.g.
517+
* {@code image/tiff} -&gt; {@code image/x-raw-nikon} for a NEF supplied without a
518+
* filename). The {@code CONTENT_TYPE_USER_OVERRIDE} key is deliberately NOT carried:
519+
* it short-circuits detection unconditionally and would let a caller force any type.
520+
*
521+
* @param tupleMetadata the caller-supplied metadata (may be null)
522+
* @param target the fresh metadata used for fetch and detection
523+
*/
524+
static void carryCallerHints(Metadata tupleMetadata, Metadata target) {
525+
if (tupleMetadata == null) {
526+
return;
527+
}
528+
String suppliedName = tupleMetadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
529+
if (!StringUtils.isBlank(suppliedName)) {
530+
target.set(TikaCoreProperties.RESOURCE_NAME_KEY, suppliedName);
531+
}
532+
String suppliedContentType = tupleMetadata.get(HttpHeaders.CONTENT_TYPE);
533+
if (!StringUtils.isBlank(suppliedContentType)) {
534+
target.set(HttpHeaders.CONTENT_TYPE, suppliedContentType);
535+
}
536+
}
520537

521538
private ParseContext setupParseContext() throws TikaException, IOException {
522539
// ContentHandlerFactory and ParseMode are retrieved from ParseContext in ParseHandler.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.pipes.core.server;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertNull;
21+
22+
import org.junit.jupiter.api.Test;
23+
24+
import org.apache.tika.io.TikaInputStream;
25+
import org.apache.tika.metadata.HttpHeaders;
26+
import org.apache.tika.metadata.Metadata;
27+
import org.apache.tika.metadata.TikaCoreProperties;
28+
import org.apache.tika.mime.MediaType;
29+
import org.apache.tika.mime.MimeTypes;
30+
import org.apache.tika.parser.ParseContext;
31+
32+
/**
33+
* Unit tests for {@link PipesWorker#carryCallerHints(Metadata, Metadata)}, which carries the
34+
* caller-supplied detection hints across the worker's fresh-metadata boundary.
35+
*/
36+
public class PipesWorkerCallerHintsTest {
37+
38+
@Test
39+
public void testCarriesResourceNameAndContentType() {
40+
Metadata tuple = new Metadata();
41+
tuple.set(TikaCoreProperties.RESOURCE_NAME_KEY, "photo.nef");
42+
tuple.set(HttpHeaders.CONTENT_TYPE, "image/x-raw-nikon");
43+
44+
Metadata target = new Metadata();
45+
PipesWorker.carryCallerHints(tuple, target);
46+
47+
assertEquals("photo.nef", target.get(TikaCoreProperties.RESOURCE_NAME_KEY));
48+
assertEquals("image/x-raw-nikon", target.get(HttpHeaders.CONTENT_TYPE));
49+
}
50+
51+
/**
52+
* The Content-Type is carried only as a soft hint. The unconditional override keys
53+
* must never be carried, or a caller could force any type past detection.
54+
*/
55+
@Test
56+
public void testDoesNotCarryOverrides() {
57+
Metadata tuple = new Metadata();
58+
tuple.set(TikaCoreProperties.CONTENT_TYPE_USER_OVERRIDE, "image/x-raw-nikon");
59+
tuple.set(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE, "image/x-raw-nikon");
60+
61+
Metadata target = new Metadata();
62+
PipesWorker.carryCallerHints(tuple, target);
63+
64+
assertNull(target.get(TikaCoreProperties.CONTENT_TYPE_USER_OVERRIDE));
65+
assertNull(target.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE));
66+
assertNull(target.get(HttpHeaders.CONTENT_TYPE));
67+
}
68+
69+
@Test
70+
public void testNullTupleIsNoOp() {
71+
Metadata target = new Metadata();
72+
target.set(TikaCoreProperties.RESOURCE_NAME_KEY, "keep.me");
73+
PipesWorker.carryCallerHints(null, target);
74+
assertEquals("keep.me", target.get(TikaCoreProperties.RESOURCE_NAME_KEY));
75+
}
76+
77+
@Test
78+
public void testBlankValuesNotCarried() {
79+
Metadata tuple = new Metadata();
80+
tuple.set(TikaCoreProperties.RESOURCE_NAME_KEY, " ");
81+
tuple.set(HttpHeaders.CONTENT_TYPE, "");
82+
83+
Metadata target = new Metadata();
84+
PipesWorker.carryCallerHints(tuple, target);
85+
86+
assertNull(target.get(TikaCoreProperties.RESOURCE_NAME_KEY));
87+
assertNull(target.get(HttpHeaders.CONTENT_TYPE));
88+
}
89+
90+
//content that magic-detects as image/tiff (little-endian TIFF marker, no CR2 marker)
91+
private static final byte[] TIFF_BYTES = {'I', 'I', 0x2A, 0x00, 0, 0, 0, 8};
92+
93+
private static MediaType detectWithCarriedContentType(String contentType) throws Exception {
94+
Metadata tuple = new Metadata();
95+
tuple.set(HttpHeaders.CONTENT_TYPE, contentType);
96+
Metadata target = new Metadata();
97+
PipesWorker.carryCallerHints(tuple, target);
98+
try (TikaInputStream tis = TikaInputStream.get(TIFF_BYTES)) {
99+
return MimeTypes.getDefaultMimeTypes().detect(tis, target, new ParseContext());
100+
}
101+
}
102+
103+
/**
104+
* A carried Content-Type that specializes the content-detected type refines detection.
105+
* image/x-canon-cr2 is a sub-class-of image/tiff, and these bytes lack the CR2 marker.
106+
*/
107+
@Test
108+
public void testSpecializingContentTypeRefinesDetection() throws Exception {
109+
assertEquals(MediaType.image("x-canon-cr2"),
110+
detectWithCarriedContentType("image/x-canon-cr2"));
111+
}
112+
113+
/**
114+
* Security boundary: a carried Content-Type that does NOT specialize the content-detected
115+
* type is ignored, so a caller cannot force an unrelated type onto the document.
116+
*/
117+
@Test
118+
public void testNonSpecializingContentTypeIgnored() throws Exception {
119+
assertEquals(MediaType.image("tiff"), detectWithCarriedContentType("audio/mpeg"));
120+
assertEquals(MediaType.image("tiff"), detectWithCarriedContentType("not-a-media-type"));
121+
}
122+
}

tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/StackTraceTest.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,11 @@ public void testEmptyParser() throws Exception {
191191
}
192192

193193

194-
// A truncated document isn't a process failure -- NOT_FOUND, not BAD_REQUEST.
194+
// Since TIKA-4825 the caller-supplied Content-Type is carried into detection, so an
195+
// explicit application/mock+xml routes the (truncated) document to the mock parser,
196+
// which cannot parse the incomplete XML. That container exception maps to 422 for the
197+
// bare-field endpoint (which has no envelope to embed it in). Without a Content-Type the
198+
// truncated bytes detect as generic XML and still yield NOT_FOUND -- see testMetaNoType.
195199
@Test
196200
public void testMeta() throws Exception {
197201
InputStream stream = ClassLoader.getSystemResourceAsStream(TEST_HELLO_WORLD);
@@ -201,6 +205,19 @@ public void testMeta() throws Exception {
201205
.type("application/mock+xml")
202206
.accept(MediaType.TEXT_PLAIN)
203207
.put(copy(stream, 100));
208+
assertEquals(422, response.getStatus());
209+
}
210+
211+
// A truncated document with no forcing Content-Type isn't a process failure --
212+
// NOT_FOUND (field missing), not BAD_REQUEST.
213+
@Test
214+
public void testMetaNoType() throws Exception {
215+
InputStream stream = ClassLoader.getSystemResourceAsStream(TEST_HELLO_WORLD);
216+
217+
Response response = WebClient
218+
.create(endPoint + "/meta" + "/Author")
219+
.accept(MediaType.TEXT_PLAIN)
220+
.put(copy(stream, 100));
204221
assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus());
205222
String msg = getStringFromInputStream((InputStream) response.getEntity());
206223
assertEquals("Failed to get metadata field Author", msg);

0 commit comments

Comments
 (0)