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 d55249ac07 TIKA-5029: Binary as text detection (#3592)
d55249ac07 is described below

commit d55249ac073e1fd5d721ad1e166a8f6d56307464
Author: Tim Allison <[email protected]>
AuthorDate: Mon Nov 9 08:58:50 2026 -0500

    TIKA-5029: Binary as text detection (#3592)
    
    * don't detect binary content as text
    
      A text glob or Content-Type no longer wins over magic-less content with 
5%+
      control bytes that is not UTF-16; DefaultDetector drops its 512-byte text
      re-sniff; zero fill after text is ignored; UTF-8 BOM + binary magic 
detects
      the magic. New/fixed magic: Emacs elc (all versions), Lua bytecode, Qt qm,
      SELinux policy package, PCP archive, binary FrameMaker (<MakerFile).
    
      Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 CHANGES.txt                                        |  19 +++
 .../org/apache/tika/detect/DefaultDetector.java    |  23 +--
 .../main/java/org/apache/tika/mime/MimeTypes.java  | 179 +++++++++++++++++++--
 .../org/apache/tika/mime/tika-mimetypes.xml        |  43 ++++-
 .../java/org/apache/tika/TikaDetectionTest.java    |   2 +-
 .../org/apache/tika/mime/MimeDetectionTest.java    | 128 +++++++++++++++
 .../apache/tika/mime/NicheFormatDetectionTest.java |  40 ++++-
 .../java/org/apache/tika/mime/TestMimeTypes.java   |   7 +-
 .../org/apache/tika/parser/pkg/ZipParserTest.java  |   5 +-
 9 files changed, 403 insertions(+), 43 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index bdb9ab009f..b92cbac3a0 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -104,6 +104,25 @@ Release 4.3.0 - ???
 
   EXTRACTION AND FORMATS
 
+   * Binary files are no longer detected as text because of their name or a 
text header.
+     When content has no magic and at least 5% control bytes, and does not 
decode as
+     UTF-16 or UTF-32, a text type from the file name or Content-Type no 
longer wins: Terminal Reality
+     .pod archives, Mozilla startupCache bytecode named .js or .xul, CODESYS 
.meta files and
+     similar are now application/octet-stream. DefaultDetector no longer 
re-sniffs the first
+     512 bytes for text after MimeTypes found the 64 KB prefix binary, so a 
binary body
+     behind a short text header (Sass caches, BSB/KAP charts) is no longer 
text/plain;
+     zero fill after lines of text (a crashed or preallocated log) does not 
count as binary.
+     A UTF-8 BOM followed by binary magic (JPEG, PNG, GIF, ID3, zip, SQLite) 
is detected by
+     that magic instead of as text (TIKA-5029).
+
+   * New or fixed magic: Emacs bytecode from every Emacs version (only version 
19 matched
+     before, and *.elc is no longer also an octet-stream glob), Lua bytecode
+     (application/x-lua-bytecode), Qt .qm translations 
(application/x-qt-translation, were
+     text/html), SELinux policy packages 
(application/x-selinux-policy-package, were
+     text/x-pascal by *.pp), Performance Co-Pilot archives 
(application/x-pcp-archive), and
+     binary FrameMaker documents ("<MakerFile"), now 
application/vnd.framemaker instead of
+     the text MIF type application/vnd.mif (TIKA-5029).
+
    * Markdown output keeps document code inside its code spans and blocks: a 
line of
      backticks inside <pre> no longer closes the fenced code block, and a line 
break inside
      inline code, alt text or a link destination, or a lone CR inside <pre>, 
no longer starts
diff --git 
a/tika-core/src/main/java/org/apache/tika/detect/DefaultDetector.java 
b/tika-core/src/main/java/org/apache/tika/detect/DefaultDetector.java
index e6cd160446..5f242dd7c6 100644
--- a/tika-core/src/main/java/org/apache/tika/detect/DefaultDetector.java
+++ b/tika-core/src/main/java/org/apache/tika/detect/DefaultDetector.java
@@ -38,7 +38,6 @@ import org.apache.tika.utils.ServiceLoaderUtils;
  * <ol>
  *   <li>MimeTypes (magic byte) detection</li>
  *   <li>Container and other detectors loaded via SPI</li>
- *   <li>TextDetector as fallback for unknown types</li>
  *   <li>Returns the most specific type detected</li>
  * </ol>
  * <p>
@@ -61,14 +60,12 @@ public class DefaultDetector extends CompositeDetector {
     private transient final ServiceLoader loader;
     private final Collection<Class<? extends Detector>> excludedClasses;
     private final MimeTypes mimeTypes;
-    private final TextDetector textDetector;
 
     public DefaultDetector(MimeTypes types, ServiceLoader loader,
                            Collection<Class<? extends Detector>> 
excludeDetectors) {
         super(types.getMediaTypeRegistry(), getDefaultDetectors(loader, 
excludeDetectors));
         this.loader = loader;
         this.mimeTypes = types;
-        this.textDetector = new TextDetector();
         this.excludedClasses = excludeDetectors != null ?
                 Collections.unmodifiableCollection(new 
ArrayList<>(excludeDetectors)) :
                 Collections.emptySet();
@@ -149,18 +146,12 @@ public class DefaultDetector extends CompositeDetector {
         // Note: Container detectors that need random access handle their own 
spooling
         MediaType detectedType = super.detect(tis, metadata, parseContext);
 
-        // 3. Text detection - only if still unknown
-        MediaType textType = null;
-        if (MediaType.OCTET_STREAM.equals(detectedType) &&
-                MediaType.OCTET_STREAM.equals(magicType)) {
-            textType = textDetector.detect(tis, metadata, parseContext);
-        }
-
-        // 4. Return most specific
-        return mostSpecific(magicType, detectedType, textType);
+        // 3. Return most specific. No text re-sniff: MimeTypes judged text 
over its full
+        // prefix, and a shorter window calls a text header on a binary body 
text.
+        return mostSpecific(magicType, detectedType);
     }
 
-    private MediaType mostSpecific(MediaType magicType, MediaType 
detectedType, MediaType textType) {
+    private MediaType mostSpecific(MediaType magicType, MediaType 
detectedType) {
         MediaTypeRegistry registry = mimeTypes.getMediaTypeRegistry();
 
         // Collect non-null, non-octet-stream candidates
@@ -188,12 +179,6 @@ public class DefaultDetector extends CompositeDetector {
             // else: best is already more specific than detectedType, keep best
         }
 
-        // Text detection as fallback only if still unknown
-        if (MediaType.OCTET_STREAM.equals(best) && textType != null &&
-                !MediaType.OCTET_STREAM.equals(textType)) {
-            best = textType;
-        }
-
         return best;
     }
 
diff --git a/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java 
b/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java
index e16eba987b..70910abd66 100644
--- a/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java
+++ b/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java
@@ -25,6 +25,7 @@ import java.io.Serializable;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -37,6 +38,7 @@ import org.apache.tika.Tika;
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.detect.Detector;
 import org.apache.tika.detect.TextDetector;
+import org.apache.tika.detect.TextStatistics;
 import org.apache.tika.detect.XmlRootExtractor;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
@@ -75,6 +77,18 @@ public final class MimeTypes implements Detector, 
Serializable {
      * Name of the {@link #xmlMimeType xml} type, application/xml.
      */
     public static final String XML = "application/xml";
+    // below this share of control bytes, a text glob still wins over 
binary-looking content
+    private static final int BINARY_CONTROL_PERCENT = 5;
+    private static final boolean[] SAFE_CONTROL = new boolean[0x20];
+    private static final int MIN_TEXT_BEFORE_NUL_TAIL = 128;
+
+    static {
+        SAFE_CONTROL['\t'] = true;
+        SAFE_CONTROL['\n'] = true;
+        SAFE_CONTROL[0x0C] = true;
+        SAFE_CONTROL['\r'] = true;
+        SAFE_CONTROL[0x1B] = true;
+    }
     /**
      * Serial version UID.
      */
@@ -236,19 +250,15 @@ public final class MimeTypes implements Detector, 
Serializable {
             return rootMimeTypeL;
         }
 
-        // Then, check for magic bytes
-        List<MimeType> result = new ArrayList<>(1);
-        int currentPriority = -1;
-        for (Magic magic : magics) {
-            if (currentPriority > 0 && currentPriority > magic.getPriority()) {
-                break;
-            }
-            if (magic.eval(data)) {
-                result.add(magic.getType());
-                currentPriority = magic.getPriority();
+        // A UTF-8 BOM in front of binary magic (a server prepending one) is 
not text
+        if (startsWithUtf8Bom(data)) {
+            List<MimeType> afterBom = binaryMagicAfterBom(data);
+            if (afterBom != null) {
+                return afterBom;
             }
         }
 
+        List<MimeType> result = matchMagics(data);
         if (!result.isEmpty()) {
             for (int i = 0; i < result.size(); i++) {
                 final MimeType matched = result.get(i);
@@ -299,7 +309,7 @@ public final class MimeTypes implements Detector, 
Serializable {
         // Finally, assume plain text if no control bytes are found
         try {
             TextDetector detector = new TextDetector(getMinLength());
-            try (TikaInputStream tis = TikaInputStream.get(data)) {
+            try (TikaInputStream tis = 
TikaInputStream.get(withoutNulTail(data))) {
                 MimeType type = forName(detector.detect(tis, new Metadata(), 
new ParseContext()).toString());
                 return Collections.singletonList(type);
             }
@@ -308,6 +318,140 @@ public final class MimeTypes implements Detector, 
Serializable {
         }
     }
 
+    private List<MimeType> matchMagics(byte[] data) {
+        List<MimeType> result = new ArrayList<>(1);
+        int currentPriority = -1;
+        for (Magic magic : magics) {
+            if (currentPriority > 0 && currentPriority > magic.getPriority()) {
+                break;
+            }
+            if (magic.eval(data)) {
+                result.add(magic.getType());
+                currentPriority = magic.getPriority();
+            }
+        }
+        return result;
+    }
+
+    private static boolean startsWithUtf8Bom(byte[] data) {
+        return data.length > 3 && data[0] == (byte) 0xEF && data[1] == (byte) 
0xBB
+                && data[2] == (byte) 0xBF;
+    }
+
+    /**
+     * Magic of a non-text type matched after the BOM, provided the rest does 
not read as text,
+     * or null.
+     */
+    private List<MimeType> binaryMagicAfterBom(byte[] data) {
+        byte[] rest = Arrays.copyOfRange(data, 3, data.length);
+        List<MimeType> result = matchMagics(rest);
+        if (result.isEmpty()) {
+            return null;
+        }
+        for (MimeType type : result) {
+            if (isTextBased(type)) {
+                return null;
+            }
+        }
+        TextStatistics stats = new TextStatistics();
+        stats.addData(rest, 0, rest.length);
+        if (stats.isMostlyAscii() || stats.looksLikeUTF8()) {
+            return null;
+        }
+        return result;
+    }
+
+    private boolean isTextBased(MimeType type) {
+        return MediaType.TEXT_PLAIN.equals(type.getType())
+                || registry.isSpecializationOf(type.getType(), 
MediaType.TEXT_PLAIN);
+    }
+
+    /**
+     * True when the prefix has too many control bytes to be text in any 
single-byte encoding
+     * or UTF-8, and does not decode as UTF-16 or UTF-32 either. Only control 
bytes count: text in a
+     * legacy 8-bit encoding is mostly high bytes and must not be called 
binary.
+     */
+    private static boolean looksBinary(byte[] data) {
+        data = withoutNulTail(data);
+        if (data.length == 0) {
+            return false;
+        }
+        int control = 0;
+        for (byte b : data) {
+            int c = b & 0xFF;
+            if (c < 0x20 && !SAFE_CONTROL[c]) {
+                control++;
+            }
+        }
+        if ((long) control * 100 < (long) data.length * 
BINARY_CONTROL_PERCENT) {
+            return false;
+        }
+        return !decodesAsUtf16(data, false) && !decodesAsUtf16(data, true)
+                && !decodesAsUtf32(data, false) && !decodesAsUtf32(data, true);
+    }
+
+    /**
+     * Drops zero fill after some lines of text (a preallocated or crashed 
log). A short header
+     * padded with zeros is left alone: that is how binary formats look.
+     */
+    private static byte[] withoutNulTail(byte[] data) {
+        int end = data.length;
+        while (end > 0 && data[end - 1] == 0) {
+            end--;
+        }
+        if (end == data.length || end < MIN_TEXT_BEFORE_NUL_TAIL) {
+            return data;
+        }
+        for (int i = 0; i < end; i++) {
+            if (data[i] == '\n') {
+                return Arrays.copyOf(data, end);
+            }
+        }
+        return data;
+    }
+
+    // script-agnostic: needs no NULs, only that the code units are plausible 
characters
+    private static boolean decodesAsUtf16(byte[] data, boolean bigEndian) {
+        int units = data.length / 2;
+        int bad = 0;
+        boolean pendingHigh = false;
+        for (int i = 0; i < units; i++) {
+            int a = data[2 * i] & 0xFF;
+            int b = data[2 * i + 1] & 0xFF;
+            char c = (char) (bigEndian ? (a << 8) | b : (b << 8) | a);
+            if (pendingHigh) {
+                pendingHigh = false;
+                if (Character.isLowSurrogate(c)) {
+                    continue;
+                }
+                bad++;
+            }
+            if (Character.isHighSurrogate(c)) {
+                pendingHigh = true;
+            } else if (Character.isLowSurrogate(c) || c == 0xFFFE || c == 
0xFFFF
+                    || (c < 0x20 && !SAFE_CONTROL[c]) || (c >= 0x7F && c < 
0xA0)) {
+                bad++;
+            }
+        }
+        return units > 0 && (long) bad * 100 < (long) units * 2;
+    }
+
+    private static boolean decodesAsUtf32(byte[] data, boolean bigEndian) {
+        int units = data.length / 4;
+        int bad = 0;
+        for (int i = 0; i < units; i++) {
+            int o = 4 * i;
+            int cp = bigEndian
+                    ? (data[o] & 0xFF) << 24 | (data[o + 1] & 0xFF) << 16 | 
(data[o + 2] & 0xFF) << 8 | data[o + 3] & 0xFF
+                    : (data[o + 3] & 0xFF) << 24 | (data[o + 2] & 0xFF) << 16 
| (data[o + 1] & 0xFF) << 8 | data[o] & 0xFF;
+            if (cp < 0 || cp > Character.MAX_CODE_POINT || (cp >= 0xD800 && cp 
<= 0xDFFF)
+                    || (cp & 0xFFFE) == 0xFFFE || (cp < 0x20 && 
!SAFE_CONTROL[cp]) || (cp >= 0x7F && cp < 0xA0)) {
+                bad++;
+            }
+        }
+        return units > 0 && (long) bad * 100 < (long) units * 2;
+    }
+
     /**
      * Reads the first {@link #getMinLength()} bytes from the given stream.
      * If the stream is shorter, then the entire content of the stream is
@@ -540,6 +684,8 @@ public final class MimeTypes implements Detector, 
Serializable {
     public MediaType detect(TikaInputStream tis, Metadata metadata, 
ParseContext parseContext)
             throws IOException {
         List<MimeType> possibleTypes = null;
+        // content with no magic that is not text: a text type from a name or 
header can't win
+        boolean binaryContent = false;
 
         // Get type based on magic prefix
         if (tis != null) {
@@ -556,6 +702,7 @@ public final class MimeTypes implements Detector, 
Serializable {
             try {
                 byte[] prefix = readMagicHeader(tis, toRead);
                 possibleTypes = getMimeType(prefix);
+                binaryContent = isRoot(possibleTypes) && looksBinary(prefix);
             } finally {
                 tis.reset();
             }
@@ -593,7 +740,7 @@ public final class MimeTypes implements Detector, 
Serializable {
 
                 // For server-side scripting languages, we cannot rely on the 
filename to detect
                 // the mime type
-                if (!(isHttp && hint.isInterpreted())) {
+                if (!(isHttp && hint.isInterpreted()) && !(binaryContent && 
isTextBased(hint))) {
                     // If we have some types based on mime magic, try to 
specialise
                     //  and/or select the type based on that
                     // Otherwise, use the type identified from the name
@@ -607,7 +754,9 @@ public final class MimeTypes implements Detector, 
Serializable {
         if (typeName != null) {
             try {
                 MimeType hint = forName(typeName);
-                possibleTypes = applyHint(possibleTypes, hint);
+                if (!(binaryContent && isTextBased(hint))) {
+                    possibleTypes = applyHint(possibleTypes, hint);
+                }
             } catch (MimeTypeException e) {
                 // Malformed type name, ignore
             }
@@ -627,6 +776,10 @@ public final class MimeTypes implements Detector, 
Serializable {
      * If the hint is a specialised form, use that instead
      * If there are multiple possible types, use the hint to select one
      */
+    private boolean isRoot(List<MimeType> possibleTypes) {
+        return possibleTypes.size() == 1 && 
rootMimeType.equals(possibleTypes.get(0));
+    }
+
     private List<MimeType> applyHint(List<MimeType> possibleTypes, MimeType 
hint) {
         if (possibleTypes == null || possibleTypes.isEmpty()) {
             return Collections.singletonList(hint);
diff --git 
a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml 
b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
index 4dafbda9ee..d3079faabe 100644
--- a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
+++ b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
@@ -790,7 +790,6 @@
     <glob pattern="*.pkg"/>
     <glob pattern="*.bpk"/>
     <glob pattern="*.dump"/>
-    <glob pattern="*.elc"/>
     <glob pattern="*.deploy"/>
   </mime-type>
 
@@ -1712,6 +1711,11 @@
   </mime-type>
   <mime-type type="application/vnd.font-fontforge-sfd"/>
   <mime-type type="application/vnd.framemaker">
+    <_comment>FrameMaker binary document</_comment>
+    <!-- above the MIF "&lt;Maker" prefix -->
+    <magic priority="60">
+      <match value="&lt;MakerFile" type="string" offset="0"/>
+    </magic>
     <glob pattern="*.fm"/>
     <glob pattern="*.frame"/>
     <glob pattern="*.maker"/>
@@ -2189,7 +2193,6 @@
     <alias type="application/x-frame"/>
     <sub-class-of type="text/plain"/>
     <magic priority="50">
-      <match value="&lt;MakerFile" type="string" offset="0" />
       <match value="&lt;MIFFile" type="string" offset="0" />
       <match value="&lt;MakerDictionary" type="string" offset="0" />
       <match value="&lt;MakerScreenFont" type="string" offset="0" />
@@ -4497,8 +4500,8 @@
     <magic priority="50">
       <!-- Emacs 18 -->
       <match value="\012(" type="string" offset="0" />
-      <!-- Emacs 19 -->
-      <match value=";ELC\023\000\000\000" type="string" offset="0" />
+      <!-- Emacs 19 and later: the fifth byte is the bytecode version -->
+      <match value=";ELC\000\000\000\000" mask="0xFFFFFFFFE0FFFFFF" 
type="string" offset="0" />
     </magic>
     <glob pattern="*.elc"/>
   </mime-type>
@@ -5097,6 +5100,14 @@
       </match>
     </magic>
   </mime-type>
+  <mime-type type="application/x-lua-bytecode">
+    <_comment>Lua bytecode</_comment>
+    <magic priority="50">
+      <match value="\033Lua" type="string" offset="0"/>
+    </magic>
+    <glob pattern="*.luac"/>
+  </mime-type>
+
   <mime-type type="application/x-lz4">
      <_comment>First match LZ4 Frame</_comment>
      <_comment>Second match Legacy Frame</_comment>
@@ -5664,6 +5675,14 @@
       <match value="PAR1" type="string" offset="0" />
     </magic>
   </mime-type>
+  <mime-type type="application/x-pcp-archive">
+    <_comment>Performance Co-Pilot archive (metadata, index or 
volume)</_comment>
+    <magic priority="50">
+      <!-- version 2 label record: length 132, then the label magic -->
+      <match value="0x0000008450052602" type="string" offset="0"/>
+    </magic>
+  </mime-type>
+
   <mime-type type="application/x-pds">
     <_comment>Planetary Data System Version 3 format</_comment>
     <tika:link>http://fileformats.archiveteam.org/wiki/PDS</tika:link>
@@ -5778,6 +5797,15 @@
     <glob pattern="*.qcow2"/>
   </mime-type>
 
+  <mime-type type="application/x-qt-translation">
+    <_comment>Qt compiled translation</_comment>
+    <!-- the leading '&lt;' would otherwise read as HTML -->
+    <magic priority="60">
+      <match value="0x3cb86418caef9c95cd211cbf60a1bddd" type="string" 
offset="0"/>
+    </magic>
+    <glob pattern="*.qm"/>
+  </mime-type>
+
   <mime-type type="application/x-quattro-pro">
     <_comment>
       Quattro Pro - Corel Spreadsheet (part of WordPerfect Office suite)
@@ -6007,6 +6035,13 @@
     <sub-class-of type="application/zip"/>
   </mime-type>
 
+  <mime-type type="application/x-selinux-policy-package">
+    <_comment>SELinux policy module package</_comment>
+    <magic priority="50">
+      <match value="0x8fff7cf9" type="string" offset="0"/>
+    </magic>
+  </mime-type>
+
   <mime-type type="application/x-sh">
     <_comment>UNIX/LINUX Shell Script</_comment>
     <magic priority="50">
diff --git a/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java 
b/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
index 517f32fd26..c09000a0c7 100644
--- a/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
+++ b/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
@@ -88,7 +88,7 @@ public class TikaDetectionTest {
         assertEquals("application/octet-stream", tika.detect("x.pkg"));
         assertEquals("application/octet-stream", tika.detect("x.bpk"));
         assertEquals("application/octet-stream", tika.detect("x.dump"));
-        assertEquals("application/octet-stream", tika.detect("x.elc"));
+        assertEquals("application/x-elc", tika.detect("x.elc"));
         assertEquals("application/octet-stream", tika.detect("x.deploy"));
         assertEquals("application/oda", tika.detect("x.oda"));
         assertEquals("application/oebps-package+xml", tika.detect("x.opf"));
diff --git 
a/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java 
b/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java
index 3c9d3a3e11..0800216d86 100644
--- a/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java
+++ b/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java
@@ -447,6 +447,134 @@ public class MimeDetectionTest {
         }
     }
 
+    @Test
+    public void testBinaryContentRefusesTextGlob() throws IOException {
+        // headers copied from real files that a text glob used to claim
+        String[][] cases = {
+                // Terminal Reality POD archive
+                {"Bulldog.pod", 
"2f000000436f6d70696c65642062792042696e456469742056657273696f6e20"
+                        + 
"322e322e38207da62d2900000000000000000000000000000000000000000000"
+                        + 
"00000000000000000000000000000000000000004152545c4241545453352e41"},
+                // Mozilla startupCache XDR bytecode, named for its source
+                {"AccountManager.js",
+                        
"0000001c000000b9000000c5fcc6bf73b9000000000000000000000000000000"
+                        + 
"0081030000f0000000b900000031000000c1030000000000003b000000000000"
+                        + 
"0000000000000000000000000022000000000000000010000000010000016368"},
+                {"overlay.xul", 
"42c073b9000000002306000096000000b9000000720000002b02000000000000"
+                        + 
"360000000000000000000000000000005d000000000000000430000001010000"
+                        + 
"016368726f6d653a2f2f676c6f62616c2f636f6e74656e742f42726f77736572"},
+                // CODESYS project .meta
+                {"1f876365.meta", 
"02200928000000000000000000000000220000000f01bf0df704c00d0180060f"
+                        + "070504f4010000c10df904000a00e70c236f3456d208"},
+                // BM-E record
+                {"10.T", 
"424d2d4564312e332d574b0031ff0d1a35460bb1000600000048000600000051"
+                        + 
"0007006f00b800080075005b000b00080077000d006f00d7000e0075001f000e"
+                        + 
"000000270117007500080019007500c3001900750005001a0075004a001b0000"}};
+        for (String[] c : cases) {
+            byte[] bytes = hex(c[1]);
+            assertEquals(MediaType.OCTET_STREAM, detect(bytes, c[0]), c[0]);
+            Metadata metadata = new Metadata();
+            metadata.set(HttpHeaders.CONTENT_TYPE, "text/plain");
+            try (TikaInputStream tis = TikaInputStream.get(bytes)) {
+                assertEquals(MediaType.OCTET_STREAM,
+                        MIME_TYPES.detect(tis, metadata, new ParseContext()), 
c[0]);
+            }
+        }
+    }
+
+    @Test
+    public void testTextGlobStillWinsForText() throws IOException {
+        String latin = "The quick brown fox jumps over the lazy dog.\n";
+        // CJK whose code units have low bytes under 0x20: NULs and controls 
in both columns
+        String cjk = "一丁七万三上下不与专 且"
+                + "世业东两 abc\n";
+        String russian = "Съешь же ещё "
+                + "этих мягких "
+                + "французских.\n";
+        MediaType log = MediaType.text("x-log");
+        assertEquals(MediaType.TEXT_PLAIN, 
detect(latin.repeat(20).getBytes(UTF_16LE), "a.txt"));
+        assertEquals(MediaType.TEXT_PLAIN, 
detect(latin.repeat(20).getBytes(UTF_16BE), "a.txt"));
+        assertEquals(MediaType.TEXT_PLAIN, 
detect(cjk.repeat(20).getBytes(UTF_16LE), "a.txt"));
+        assertEquals(MediaType.TEXT_PLAIN, 
detect(cjk.repeat(20).getBytes(UTF_16BE), "a.txt"));
+        for (String utf32 : new String[] {"UTF-32LE", "UTF-32BE"}) {
+            assertEquals(MediaType.TEXT_PLAIN, 
detect(latin.repeat(20).getBytes(utf32), "a.txt"), utf32);
+            assertEquals(MediaType.TEXT_PLAIN, 
detect(cjk.repeat(20).getBytes(utf32), "a.txt"), utf32);
+            Metadata metadata = new Metadata();
+            metadata.set(HttpHeaders.CONTENT_TYPE, "text/plain");
+            try (TikaInputStream tis = 
TikaInputStream.get(russian.repeat(20).getBytes(utf32))) {
+                assertEquals(MediaType.TEXT_PLAIN, MIME_TYPES.detect(tis, 
metadata, new ParseContext()), utf32);
+            }
+        }
+        assertEquals(MediaType.TEXT_PLAIN,
+                detect(russian.repeat(20).getBytes("windows-1251"), "a.txt"));
+        assertEquals(log, detect(("\u001b[32mINFO\u001b[0m 
started\n").repeat(40)
+                .getBytes(US_ASCII), "server.log"));
+        // a stray control byte per line stays under the binary threshold
+        assertEquals(log, detect(("2026-11-07 12:00:00 ok\u0000\n").repeat(40)
+                .getBytes(US_ASCII), "server.log"));
+        // zero fill after the text, as a crashed or preallocated log leaves
+        byte[] text = "[Mon Oct 26 03:26:03 2015] [notice] SIGHUP 
received.\n".repeat(18)
+                .getBytes(US_ASCII);
+        byte[] zeroFilled = java.util.Arrays.copyOf(text, 8192);
+        assertEquals(log, detect(zeroFilled, "error_log.log"));
+        assertEquals(MediaType.TEXT_PLAIN, detect(zeroFilled, null));
+    }
+
+    @Test
+    public void testUtf8BomBeforeBinaryMagic() throws IOException {
+        String bom = "efbbbf";
+        assertEquals(MediaType.image("jpeg"),
+                detect(hex(bom + 
"ffd8ffe000104a46494600010101004800480000ffdb004300080606"), null));
+        assertEquals(MediaType.image("png"),
+                detect(hex(bom + 
"89504e470d0a1a0a0000000d4948445200000710000004b008060000"), null));
+        assertEquals(MediaType.audio("mpeg"),
+                detect(hex(bom + 
"49443303000000001f76434f4d4d0000001e000000646575007777"), null));
+        assertEquals(MediaType.image("gif"),
+                detect(hex(bom + 
"4749463839610a000a00f70000000000800000008000808000000080"), null));
+        assertEquals(MediaType.application("zip"),
+                detect(hex(bom + 
"504b03041400000008001c3f364d479244b25801000000a3d4b2"), null));
+        // text that merely starts with a magic string stays text
+        assertEquals(MediaType.TEXT_PLAIN,
+                detect("ID3 tags hold the title and 
artist.\n".getBytes(UTF_8), null));
+        assertEquals(MediaType.TEXT_PLAIN,
+                detect("GIF89a is the animated variant.\n".getBytes(UTF_8), 
"notes.txt"));
+    }
+
+    @Test
+    public void testTextHeaderOnBinaryBodyIsNotText() throws IOException {
+        // BSB/KAP chart: text header lines, then 0x1A 0x00 and raster bytes
+        StringBuilder header = new 
StringBuilder("VER/2.0\r\nBSB/NA=Mallorca,NU=1\r\n");
+        for (int i = 0; header.length() < 1900; i++) {
+            header.append("RGB/").append(i).append(',').append(i * 7 % 
256).append(',')
+                    .append(i * 13 % 256).append(',').append(i * 3 % 
256).append("\r\n");
+        }
+        byte[] text = header.toString().getBytes(US_ASCII);
+        byte[] raster = new byte[4000];
+        new java.util.Random(42).nextBytes(raster);
+        byte[] kap = new byte[text.length + 2 + raster.length];
+        System.arraycopy(text, 0, kap, 0, text.length);
+        kap[text.length] = 0x1A;
+        System.arraycopy(raster, 0, kap, text.length + 2, raster.length);
+        org.apache.tika.detect.Detector detector =
+                new org.apache.tika.detect.DefaultDetector(MIME_TYPES);
+        try (TikaInputStream tis = TikaInputStream.get(kap)) {
+            assertEquals(MediaType.OCTET_STREAM,
+                    detector.detect(tis, new Metadata(), new ParseContext()));
+        }
+        try (TikaInputStream tis = TikaInputStream.get(text)) {
+            assertEquals(MediaType.TEXT_PLAIN,
+                    detector.detect(tis, new Metadata(), new ParseContext()));
+        }
+    }
+
+    private static byte[] hex(String hex) {
+        byte[] bytes = new byte[hex.length() / 2];
+        for (int i = 0; i < bytes.length; i++) {
+            bytes[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 
2), 16);
+        }
+        return bytes;
+    }
+
     @Test
     public void testXulByContent() throws IOException {
         MediaType xul = MediaType.application("vnd.mozilla.xul+xml");
diff --git 
a/tika-core/src/test/java/org/apache/tika/mime/NicheFormatDetectionTest.java 
b/tika-core/src/test/java/org/apache/tika/mime/NicheFormatDetectionTest.java
index cac379a631..2c27de30fe 100644
--- a/tika-core/src/test/java/org/apache/tika/mime/NicheFormatDetectionTest.java
+++ b/tika-core/src/test/java/org/apache/tika/mime/NicheFormatDetectionTest.java
@@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test;
 
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
 
 /**
@@ -340,6 +341,34 @@ public class NicheFormatDetectionTest {
         }
     }
 
+    @Test
+    public void testCompiledFormatsThatLookLikeText() throws Exception {
+        MediaType elc = MediaType.application("x-elc");
+        assertDetected(elc, ";ELC\u0013\u0000\u0000\u0000\n;;; Compiled by");
+        assertDetected(elc, ";ELC\u0017\u0000\u0000\u0000\n;;; Compiled", 
"pymacs.elc");
+        assertDetected(MediaType.application("x-lua-bytecode"),
+                
"\u001bLuaQ\u0000\u0001\u0004\u0004\u0004\b\u0000\u001a\u0000\u0000\u0000@light",
+                "gui.lua");
+        // the leading '<' used to read as HTML
+        assertDetected(MediaType.application("x-qt-translation"),
+                
"<\u00b8d\u0018\u00ca\u00ef\u009c\u0095\u00cd!\u001c\u00bf`\u00a1\u00bd\u00dd"
+                        + "B\u0000\u0000\u0000\u00a0\u0000\u0000\u00001", 
"REDHAT-582476-0.tgz-167.html");
+        assertDetected(MediaType.application("x-selinux-policy-package"),
+                
"\u008f\u00ff|\u00f9\u0001\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0010"
+                        + "\u0000\u0000\u0000\u008d\u00ff|\u00f9\u000f", 
"mypol-1.pp");
+        assertDetected(MediaType.application("x-pcp-archive"),
+                
"\u0000\u0000\u0000\u0084P\u0005&\u0002\u0000\u0000\u0001\u0083VT\u0082\u00b6"
+                        + "\u0000\u0000\u00fc%\u00ff\u00ff\u00ff\u00ff", 
"pVZ3Sj.localhost.meta");
+    }
+
+    @Test
+    public void testFrameMakerBinaryIsNotMif() throws Exception {
+        MediaType frame = MediaType.application("vnd.framemaker");
+        assertDetected(frame, "<MakerFile 5.5Q>\r\n\u001a\u0000\u0000\b\t\n", 
"TIKA-1898-0.mif");
+        assertDetected(frame, "<MakerFile 7.0H>\r\n\u001a\u0000\u0000\b\t\n");
+        assertDetected(MediaType.application("vnd.mif"), "<MIFFile 5.50> # 
Generated by FrameMaker");
+    }
+
     @Test
     public void testSharedExtensionsAreNotClaimed() throws Exception {
         // .atr Atari disks, .bsp Quake/GoldSrc maps, .lev other games, 
.flipchart Wacom
@@ -354,11 +383,20 @@ public class NicheFormatDetectionTest {
     }
 
     private static void assertDetected(MediaType expected, String latin1) 
throws IOException {
+        assertDetected(expected, latin1, null);
+    }
+
+    private static void assertDetected(MediaType expected, String latin1, 
String name)
+            throws IOException {
         byte[] bytes = new byte[64];
         byte[] header = latin1.getBytes(ISO_8859_1);
         System.arraycopy(header, 0, bytes, 0, header.length);
+        Metadata metadata = new Metadata();
+        if (name != null) {
+            metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, name);
+        }
         try (TikaInputStream tis = TikaInputStream.get(bytes)) {
-            assertEquals(expected, MIME_TYPES.detect(tis, new Metadata(), new 
ParseContext()));
+            assertEquals(expected, MIME_TYPES.detect(tis, metadata, new 
ParseContext()));
         }
     }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
index e6ee585b45..f24d79b8d6 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
@@ -1280,8 +1280,11 @@ public class TestMimeTypes {
 
     @Test
     public void testMIF() throws Exception {
-        assertType("application/vnd.mif", "testMIF.mif");
-        assertTypeByData("application/vnd.mif", "testMIF.mif");
+        // a binary FrameMaker document (<MakerFile), not the MIF text format
+        assertType("application/vnd.framemaker", "testMIF.mif");
+        assertTypeByData("application/vnd.framemaker", "testMIF.mif");
+        assertType("application/vnd.mif", "testFramemakerMif.mif");
+        assertTypeByData("application/vnd.mif", "testFramemakerMif.mif");
     }
 
     @Test
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
index a3b7f56902..af6613128d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
@@ -142,9 +142,8 @@ public class ZipParserTest extends AbstractPkgTest {
         //mime is 0 bytes
         assertContains("InputStream must have > 0 bytes",
                 results.get(1).get("tk:exception:embedded-exception"));
-        //source.xml is binary, not xml
-        assertContains("TikaException: XML parse error",
-                results.get(2).get("tk:exception:embedded-exception"));
+        //source.xml is binary, not xml: the .xml name no longer wins
+        assertEquals("application/octet-stream", 
results.get(2).get(HttpHeaders.CONTENT_TYPE));
         //manifest.xml has malformed xml
         assertContains("TikaException: XML parse error",
                 results.get(4).get("tk:exception:embedded-exception"));

Reply via email to