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 65a2f3ab6f TIKA-4796: compile MagicDetector regex once at construction 
(#2968)
65a2f3ab6f is described below

commit 65a2f3ab6fb427855d9e09a6954fac52c8bcde76
Author: Grant Ingersoll <[email protected]>
AuthorDate: Tue Jul 28 13:36:13 2026 -0400

    TIKA-4796: compile MagicDetector regex once at construction (#2968)
    
    MagicDetector recompiled its Pattern on every regex match even though
    the pattern bytes and the case-insensitivity flag are both fixed at
    construction time. Compile once in the constructor and reuse the
    Pattern; Pattern is immutable and its Matcher is still created per
    call, so the regex path stays thread-safe.
    
    Adds direct coverage for the regex branch of matches(byte[]), which
    MagicMatch.eval uses for every magic in tika-mimetypes.xml but which
    was previously only exercised indirectly, plus repeated-call and
    concurrent-use tests over a shared detector instance.
    
    
    Claude-Session: https://claude.ai/code/session_01Pp6TVDKF7ztw7SS7hwwuFd
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 CHANGES.txt                                        |   5 +
 .../java/org/apache/tika/detect/MagicDetector.java |  35 +++++--
 .../org/apache/tika/detect/MagicDetectorTest.java  | 102 +++++++++++++++++++++
 3 files changed, 134 insertions(+), 8 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 0d31e36091..e46059d1a1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -6,6 +6,11 @@ Release 4.0.0 - ???
      opt-in Pkcs7Detector surfaces the subtype at detect() time,
      but must be enabled via configuration (TIKA-1997).
 
+  OTHER CHANGES
+
+   * MagicDetector now compiles its regular expression once, in the
+     constructor, instead of recompiling it on every match (TIKA-4796).
+
 
 Release 4.0.0-beta-1 - 6/29/2026
 
diff --git a/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java 
b/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java
index ad59bd4751..80a7725453 100644
--- a/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java
+++ b/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java
@@ -41,6 +41,9 @@ import org.apache.tika.parser.ParseContext;
  * Because this works on bytes, not characters, by default any string
  * matching is done as ISO_8859_1. To use an explicit different
  * encoding, supply a type other than "string" / "stringignorecase"
+ * <p>
+ * Instances of this class are immutable and safe for use by multiple
+ * concurrent threads.
  *
  * @since Apache Tika 0.3
  */
@@ -94,6 +97,12 @@ public class MagicDetector implements Detector {
      * starts at this offset.
      */
     private final int offsetRangeEnd;
+    /**
+     * The compiled form of {@link #pattern} when {@link #isRegex} is true,
+     * <code>null</code> otherwise. Compiled once here rather than per match,
+     * as every input to it is fixed at construction time.
+     */
+    private final Pattern compiledPattern;
 
     /**
      * Creates a detector for input documents that have the exact given byte
@@ -140,6 +149,16 @@ public class MagicDetector implements Detector {
     /**
      * Creates a detector for input documents that meet the specified
      * magic match.
+     * <p>
+     * When <code>isRegex</code> is true the pattern is compiled here rather
+     * than on each match, so a malformed pattern is reported by this
+     * constructor instead of by the first call to
+     * {@link #detect(TikaInputStream, Metadata, ParseContext)} or
+     * {@link #matches(byte[])}.
+     *
+     * @throws java.util.regex.PatternSyntaxException if <code>isRegex</code>
+     *         is true and <code>pattern</code> is not a valid regular
+     *         expression
      */
     public MagicDetector(MediaType type, byte[] pattern, byte[] mask, boolean 
isRegex,
                          boolean isStringIgnoreCase, int offsetRangeBegin, int 
offsetRangeEnd) {
@@ -183,6 +202,13 @@ public class MagicDetector implements Detector {
             }
         }
 
+        if (this.isRegex) {
+            int flags = this.isStringIgnoreCase ? Pattern.CASE_INSENSITIVE : 0;
+            this.compiledPattern = Pattern.compile(new String(this.pattern, 
UTF_8), flags);
+        } else {
+            this.compiledPattern = null;
+        }
+
         this.offsetRangeBegin = offsetRangeBegin;
         this.offsetRangeEnd = offsetRangeEnd;
     }
@@ -444,20 +470,13 @@ public class MagicDetector implements Detector {
      */
     private boolean matchesBuffer(byte[] buffer, int startOffset, int 
endOffset) {
         if (this.isRegex) {
-            int flags = 0;
-            if (this.isStringIgnoreCase) {
-                flags = Pattern.CASE_INSENSITIVE;
-            }
-
-            Pattern p = Pattern.compile(new String(this.pattern, UTF_8), 
flags);
-
             int bufferLen = Math.min(buffer.length - startOffset, length + 
(endOffset - startOffset));
             if (bufferLen <= 0) {
                 return false;
             }
             ByteBuffer bb = ByteBuffer.wrap(buffer, startOffset, bufferLen);
             CharBuffer result = ISO_8859_1.decode(bb);
-            Matcher m = p.matcher(result);
+            Matcher m = compiledPattern.matcher(result);
 
             // Loop until we've covered the entire offset range
             for (int i = 0; i <= endOffset - startOffset; i++) {
diff --git 
a/tika-core/src/test/java/org/apache/tika/detect/MagicDetectorTest.java 
b/tika-core/src/test/java/org/apache/tika/detect/MagicDetectorTest.java
index 4023923065..227029824b 100644
--- a/tika-core/src/test/java/org/apache/tika/detect/MagicDetectorTest.java
+++ b/tika-core/src/test/java/org/apache/tika/detect/MagicDetectorTest.java
@@ -20,11 +20,19 @@ import static java.nio.charset.StandardCharsets.US_ASCII;
 import static java.nio.charset.StandardCharsets.UTF_16BE;
 import static java.nio.charset.StandardCharsets.UTF_16LE;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 
 import org.apache.commons.io.IOUtils;
 import org.junit.jupiter.api.Test;
@@ -207,6 +215,100 @@ public class MagicDetectorTest extends TikaTest {
         assertDetect(detector, testMT, data.getBytes(US_ASCII));
     }
 
+    /**
+     * The byte[] path is what MagicMatch.eval uses for every magic in
+     * tika-mimetypes.xml, but it was only ever exercised indirectly. Cover the
+     * regex branch of it directly.
+     */
+    @Test
+    public void testMatchesByteArrayRegEx() {
+        MediaType pdf = new MediaType("application", "pdf");
+        MagicDetector detector =
+                new MagicDetector(pdf, 
"(?s)\\A.{0,144}%PDF-".getBytes(US_ASCII), null, true, 0, 0);
+
+        assertTrue(detector.matches("%PDF-1.0".getBytes(US_ASCII)));
+        assertTrue(detector.matches(("0        10        20        30        
40        50        6" +
+                "0        70        80        90        100       110       1" 
+
+                "20       130       140" + "34%PDF-1.0").getBytes(US_ASCII)));
+        assertFalse(detector.matches(("0        10        20        30        
40        50        6" +
+                "0        70        80        90        100       110       1" 
+
+                "20       130       140" + "345%PDF-1.0").getBytes(US_ASCII)));
+        assertFalse(detector.matches("".getBytes(US_ASCII)));
+        assertFalse(detector.matches(null));
+
+        // an offset range, mirroring the wider windows used in 
tika-mimetypes.xml
+        MediaType xhtml = new MediaType("application", "xhtml+xml");
+        String pattern = "(?s)\\x3chtml 
xmlns=\"http://www\\.w3\\.org/1999/xhtml"; +
+                "\".*\\x3ctitle\\x3e.*\\x3c/title\\x3e";
+        MagicDetector ranged =
+                new MagicDetector(xhtml, pattern.getBytes(US_ASCII), null, 
true, 0, 8192);
+        assertTrue(ranged.matches(("<html 
xmlns=\"http://www.w3.org/1999/xhtml\";>" +
+                "<head><title>XHTML test 
document</title></head>").getBytes(US_ASCII)));
+        assertFalse(ranged.matches("<html><head><title>no 
namespace</title></head>"
+                .getBytes(US_ASCII)));
+    }
+
+    /**
+     * A MagicDetector is built once and reused for the life of the process, so
+     * repeated calls must be independent of each other. Guards the compiled
+     * Pattern against per-call state leaking in.
+     */
+    @Test
+    public void testRegExDetectorRepeatedCallsStable() throws Exception {
+        MediaType html = new MediaType("text", "html");
+        String pattern = "(?s)\\A.{0,1024}\\x3c\\!(?:DOCTYPE|doctype) 
(?:HTML|html) ";
+        MagicDetector detector =
+                new MagicDetector(html, pattern.getBytes(US_ASCII), null, 
true, 0, 0);
+
+        byte[] match = "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 
4.01//EN\">".getBytes(US_ASCII);
+        byte[] noMatch = 
"<html><head><title>plain</title></head>".getBytes(US_ASCII);
+
+        for (int i = 0; i < 100; i++) {
+            assertTrue(detector.matches(match), "matches() changed on 
iteration " + i);
+            assertFalse(detector.matches(noMatch), "matches() changed on 
iteration " + i);
+            assertDetect(detector, html, match);
+            assertDetect(detector, MediaType.OCTET_STREAM, noMatch);
+        }
+    }
+
+    /**
+     * MimeTypes shares one MagicDetector instance per magic clause across 
every
+     * caller, so the regex path has to be safe to use concurrently.
+     */
+    @Test
+    public void testRegExDetectorConcurrent() throws Exception {
+        MediaType pdf = new MediaType("application", "pdf");
+        MagicDetector detector =
+                new MagicDetector(pdf, 
"(?s)\\A.{0,144}%PDF-".getBytes(US_ASCII), null, true, 0, 0);
+
+        byte[] match = "%PDF-1.4\nsome trailing content".getBytes(US_ASCII);
+        byte[] noMatch = "not a pdf at all".getBytes(US_ASCII);
+
+        int threads = 8;
+        int iterations = 200;
+        ExecutorService executor = Executors.newFixedThreadPool(threads);
+        try {
+            List<Future<?>> futures = new ArrayList<>();
+            for (int t = 0; t < threads; t++) {
+                futures.add(executor.submit(() -> {
+                    for (int i = 0; i < iterations; i++) {
+                        assertTrue(detector.matches(match));
+                        assertFalse(detector.matches(noMatch));
+                        assertEquals(pdf, 
detector.detect(TikaInputStream.get(match), new Metadata(),
+                                new ParseContext()));
+                    }
+                    return null;
+                }));
+            }
+            for (Future<?> future : futures) {
+                // an assertion failure on a worker surfaces here as an 
ExecutionException
+                future.get(60, TimeUnit.SECONDS);
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
     private void assertDetect(Detector detector, MediaType type, String data) {
         byte[] bytes = data.getBytes(US_ASCII);
         assertDetect(detector, type, bytes);

Reply via email to