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 da7e4d80a2 TIKA-4878: zip and friends (#3134)
da7e4d80a2 is described below

commit da7e4d80a200f7902485e30014bc36aef8e99fc1
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 4 15:09:10 2026 -0400

    TIKA-4878: zip and friends (#3134)
---
 CHANGES.txt                                        |  12 +-
 .../tika/extractor/RewindRecordingExtractor.java   |  85 ++++++++++++++
 .../tika/parser/apple/AppleSingleFileParser.java   |  58 +++++++---
 .../parser/iwork/iwana/IWork13PackageParser.java   |   3 +-
 .../parser/iwork/iwana/IWork18PackageParser.java   |   9 +-
 .../apple/AppleSingleFileParserNoTempFileTest.java | 100 ++++++++++++++++
 .../org/apache/tika/parser/mbox/MboxParser.java    |  13 ++-
 .../tika/parser/mbox/MboxParserNoTempFileTest.java |  62 ++++++++++
 .../org/apache/tika/parser/epub/EpubParser.java    |  20 ++--
 .../tika/parser/geogebra/GeoGebraParser.java       |  12 +-
 .../apache/tika/parser/odf/OpenDocumentParser.java |  12 +-
 .../parser/odf/EmbeddedEntriesNoTempFileTest.java  | 128 +++++++++++++++++++++
 .../org/apache/tika/parser/wacz/WACZParser.java    |  16 ++-
 .../org/apache/tika/parser/xliff/XLZParser.java    |   4 +-
 14 files changed, 488 insertions(+), 46 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index f1a0032763..f15a629c07 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,14 @@
 Release 4.1.0 - unreleased
 
+   * Entries of ODF, EPUB, GeoGebra, WACZ, XLZ and iWork containers, mbox
+     messages and the AppleSingle data fork are re-opened from their
+     container on rewind instead of cached: digesting rewinds every
+     embedded document, and the cached copy cost heap for the whole entry
+     and, past the cache budget or the 1 MB floor, a temp file. AppleSingle
+     no longer spools its data fork to a temp file on every parse, and
+     GeoGebra no longer spools every embedded picture to detect it
+     (TIKA-4878).
+
    * A declared Content-Length is no longer treated as a measurement: the
      zip-bomb ratio counts only measured input bytes (a container-declared
      size on an embedded document could inflate its denominator), and a
@@ -7,6 +16,7 @@ Release 4.1.0 - unreleased
      from the declared length (a lying one could push a small payload to
      disk or churn the shared budget). Neither is in a release: the
      exposure arrived with TIKA-4868 and TIKA-4873 (TIKA-4878).
+
    * Embedded objects in Office documents are re-opened from their container
      instead of cached: every OOXML part (pictures, media, attachments), the
      OLE 2.0 package inside an OOXML part, the CONTENTS entry of an OLE 2.0
@@ -15,7 +25,7 @@ Release 4.1.0 - unreleased
      the cached copy that made possible cost heap for the whole object and,
      past the cache budget or the 1 MB floor, a temp file. The container
      hands the bytes back on demand, so neither is needed (TIKA-4878).
-     
+
    * PDF attachments, PDF XMP packets, 3D on-instantiate scripts and PST
      attachments are re-opened from their document instead of cached when
      the embedded-document extractor rewinds them (digesting does, for every
diff --git 
a/tika-core/src/test/java/org/apache/tika/extractor/RewindRecordingExtractor.java
 
b/tika-core/src/test/java/org/apache/tika/extractor/RewindRecordingExtractor.java
new file mode 100644
index 0000000000..0f27865bf9
--- /dev/null
+++ 
b/tika-core/src/test/java/org/apache/tika/extractor/RewindRecordingExtractor.java
@@ -0,0 +1,85 @@
+/*
+ * 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.extractor;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.xml.sax.ContentHandler;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Test double for parsers that hand embedded documents over. Rewinds each
+ * embedded stream the way a digester does, then records whether that left the
+ * stream backed by a temp file and how many bytes it still yields.
+ * <p>
+ * Assert on what this extractor was handed, not on a watched temp directory:
+ * a parser owns each child's {@code TemporaryResources}, so a directory watch
+ * passes whatever the parser did.
+ */
+public class RewindRecordingExtractor implements EmbeddedDocumentExtractor {
+
+    private final List<Boolean> spooled = new ArrayList<>();
+    private final List<Integer> lengths = new ArrayList<>();
+
+    @Override
+    public boolean shouldParseEmbedded(Metadata metadata, ParseContext 
context) {
+        return true;
+    }
+
+    @Override
+    public void parseEmbedded(TikaInputStream stream, ContentHandler handler, 
Metadata metadata,
+                              ParseContext context, boolean outputHtml) throws 
IOException {
+        stream.enableRewind();
+        stream.readAllBytes();
+        stream.rewind();
+        spooled.add(stream.hasFile());
+        lengths.add(stream.readAllBytes().length);
+    }
+
+    /** Byte counts of the embedded documents seen, in order. */
+    public List<Integer> lengths() {
+        return Collections.unmodifiableList(lengths);
+    }
+
+    public void assertSawLength(int length) {
+        assertTrue(lengths.contains(length),
+                "an embedded document of " + length + " bytes reached the 
extractor; saw "
+                        + lengths);
+    }
+
+    public void assertSawLengthAtLeast(int length) {
+        assertTrue(lengths.stream().anyMatch(l -> l >= length),
+                "an embedded document of at least " + length + " bytes reached 
the extractor; saw "
+                        + lengths);
+    }
+
+    public void assertNothingSpooled() {
+        for (int i = 0; i < spooled.size(); i++) {
+            assertFalse(spooled.get(i), "embedded stream " + i + " (" + 
lengths.get(i)
+                    + " bytes) was spooled to disk to rewind instead of 
re-opened");
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/apple/AppleSingleFileParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/apple/AppleSingleFileParser.java
index 4628a47726..8808b40822 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/apple/AppleSingleFileParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/apple/AppleSingleFileParser.java
@@ -18,6 +18,8 @@ package org.apache.tika.parser.apple;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.channels.Channels;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -35,7 +37,9 @@ import org.apache.tika.exception.TikaException;
 import org.apache.tika.exception.TikaMemoryLimitException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.CacheMemoryBudget;
 import org.apache.tika.io.EndianUtils;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
@@ -86,36 +90,54 @@ public class AppleSingleFileParser implements Parser {
 
         EmbeddedDocumentExtractor ex = 
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
 
+        //the data fork is handed over as a region of this stream, which needs 
a
+        //seekable view after the header has been read sequentially
+        tis.enableRewind(context.get(CacheMemoryBudget.class));
         short numEntries = readThroughNumEntries(tis);
-        long bytesRead = 26;
         List<FieldInfo> fieldInfoList = getSortedFieldInfoList(tis, 
numEntries);
-        bytesRead += 12 * numEntries;
         Metadata embeddedMetadata = Metadata.newInstance(context);
-        bytesRead = processFieldEntries(tis, fieldInfoList, embeddedMetadata, 
bytesRead);
+        processFieldEntries(tis, fieldInfoList, embeddedMetadata, 26 + 12L * 
numEntries);
         FieldInfo contentFieldInfo = getContentFieldInfo(fieldInfoList);
         XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, 
context);
         xhtml.startDocument();
-        if (contentFieldInfo != null) {
-            long diff = contentFieldInfo.offset - bytesRead;
-            IOUtils.skipFully(tis, diff);
-            if (ex.shouldParseEmbedded(embeddedMetadata, context)) {
-                // Use BoundedInputStream to limit bytes read, then spool to 
temp file
-                // for complete isolation from parent stream (reset() goes to 
embedded start)
-                BoundedInputStream bounded =
-                        BoundedInputStream.builder()
-                                .setInputStream(tis)
-                                .setMaxCount(contentFieldInfo.length)
-                                .get();
-                try (TikaInputStream inner = TikaInputStream.get(bounded)) {
-                    inner.getPath();
-                    ex.parseEmbedded(inner, xhtml, embeddedMetadata, context, 
true);
-                }
+        if (contentFieldInfo != null && 
ex.shouldParseEmbedded(embeddedMetadata, context)) {
+            //re-opened from the channel on rewind: a digest re-reads the fork 
in place
+            //instead of the copy-and-spool that getPath() used to force on 
every parse
+            long offset = contentFieldInfo.offset;
+            long length = contentFieldInfo.length;
+            try (TikaInputStream inner = TikaInputStream.get(() -> region(tis, 
offset, length),
+                    new TemporaryResources(), null)) {
+                ex.parseEmbedded(inner, xhtml, embeddedMetadata, context, 
true);
             }
         }
         xhtml.endDocument();
 
     }
 
+    /**
+     * The data fork as a fresh stream over the parent's seekable channel: in 
memory
+     * when the parent is, from its file when it has one. The offset and 
length are
+     * the file's own claims; a region past the end simply reads as empty.
+     */
+    private static InputStream region(TikaInputStream tis, long offset, long 
length)
+            throws IOException {
+        if (offset < 0 || length < 0) {
+            throw new IOException("AppleSingle data fork out of range: 
offset=" + offset +
+                    " length=" + length);
+        }
+        SeekableByteChannel channel = tis.getSeekableByteChannel();
+        try {
+            channel.position(offset);
+            return BoundedInputStream.builder()
+                    .setInputStream(Channels.newInputStream(channel))
+                    .setMaxCount(length)
+                    .get();
+        } catch (IOException e) {
+            channel.close();
+            throw e;
+        }
+    }
+
     private FieldInfo getContentFieldInfo(List<FieldInfo> fieldInfoList) {
         for (FieldInfo fieldInfo : fieldInfoList) {
             if (fieldInfo.entryId == 1) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork13PackageParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork13PackageParser.java
index 61c273d281..37cbef7a4e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork13PackageParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork13PackageParser.java
@@ -155,7 +155,8 @@ public class IWork13PackageParser implements Parser {
             if (type == null) {
                 type = IWork13DocumentType.detectIfPossible(entry);
             }
-            try (TikaInputStream tis = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+            try (TikaInputStream tis = TikaInputStream.get(() -> 
zipFile.getInputStream(entry),
+                    new TemporaryResources(), null)) {
                 processZipEntry(entry, tis, metadata, xhtml, parseContext, 
embeddedDocumentExtractor);
             } catch (SecurityException e) {
                 throw e;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
index a98566855d..b6a860d2fc 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
@@ -35,6 +35,7 @@ import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -92,8 +93,7 @@ public class IWork18PackageParser implements Parser {
                     type = IWork18DocumentType.detectIfPossible(entry);
                 }
                 if (isPreview(entry) && zipFile.canReadEntryData(entry)) {
-                    try (TikaInputStream previewStream =
-                                 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+                    try (TikaInputStream previewStream = entryStream(zipFile, 
entry)) {
                         handleThumbnail(entry, previewStream, xhtml, context);
                     }
                 }
@@ -127,6 +127,11 @@ public class IWork18PackageParser implements Parser {
         String name = entry.getName();
         return name.equals("preview.jpg") || name.endsWith("/preview.jpg");
     }
+    /** Re-opened from the zip on rewind rather than cached: the entry is in 
the container already. */
+    private static TikaInputStream entryStream(ZipFile zipFile, 
ZipArchiveEntry entry) {
+        return TikaInputStream.get(() -> zipFile.getInputStream(entry), new 
TemporaryResources(), null);
+    }
+
 
     private static void handleThumbnail(ZipEntry entry, TikaInputStream 
previewStream,
                                         XHTMLContentHandler xhtml, 
ParseContext context)
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/apple/AppleSingleFileParserNoTempFileTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/apple/AppleSingleFileParserNoTempFileTest.java
new file mode 100644
index 0000000000..6a91971164
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/apple/AppleSingleFileParserNoTempFileTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.parser.apple;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Random;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.RewindRecordingExtractor;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * The data fork of an AppleSingle file is a region of the file itself. The
+ * stream handed to the embedded-document extractor must re-read that region on
+ * rewind, from memory when the parent is in memory and from the file when it
+ * has one, rather than copy it to a temp file. The parser used to force that
+ * copy on every parse, digester or not.
+ */
+public class AppleSingleFileParserNoTempFileTest {
+
+    private static final int PAYLOAD_LENGTH = 2 * 1024 * 1024;
+    private static final int HEADER_LENGTH = 26 + 12;
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    public void testDataForkFromFileIsNotSpooled() throws Exception {
+        Path file = tempDir.resolve("fork.as");
+        Files.write(file, appleSingle());
+        try (TikaInputStream tis = TikaInputStream.get(file, new Metadata())) {
+            RewindRecordingExtractor extractor = parse(tis);
+            extractor.assertSawLength(PAYLOAD_LENGTH);
+            extractor.assertNothingSpooled();
+        }
+    }
+
+    @Test
+    public void testDataForkFromMemoryIsNotSpooled() throws Exception {
+        try (TikaInputStream tis = TikaInputStream.get(appleSingle())) {
+            RewindRecordingExtractor extractor = parse(tis);
+            extractor.assertSawLength(PAYLOAD_LENGTH);
+            extractor.assertNothingSpooled();
+        }
+    }
+
+    private static RewindRecordingExtractor parse(TikaInputStream tis) throws 
Exception {
+        RewindRecordingExtractor extractor = new RewindRecordingExtractor();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, extractor);
+        new AppleSingleFileParser().parse(tis, new DefaultHandler(), new 
Metadata(), context);
+        return extractor;
+    }
+
+    /** Header, one entry (the data fork, id 1) and the fork itself. */
+    private static byte[] appleSingle() {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        int32(out, 0x00051600);
+        int32(out, 0x00020000);
+        out.writeBytes(new byte[16]);
+        out.write(0);
+        out.write(1);
+        int32(out, 1);
+        int32(out, HEADER_LENGTH);
+        int32(out, PAYLOAD_LENGTH);
+        byte[] payload = new byte[PAYLOAD_LENGTH];
+        new Random(4878).nextBytes(payload);
+        out.writeBytes(payload);
+        return out.toByteArray();
+    }
+
+    private static void int32(ByteArrayOutputStream out, int v) {
+        out.write((v >>> 24) & 0xFF);
+        out.write((v >>> 16) & 0xFF);
+        out.write((v >>> 8) & 0xFF);
+        out.write(v & 0xFF);
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mbox/MboxParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mbox/MboxParser.java
index 0aea3dcd50..da909baac9 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mbox/MboxParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mbox/MboxParser.java
@@ -40,6 +40,7 @@ import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.KeyPrefix;
@@ -144,11 +145,13 @@ public class MboxParser implements Parser {
                         saveHeaderInMetadata(mailMetadata, item);
                     }
 
-                    TikaInputStream msgStream = 
TikaInputStream.get(message.toInputStream());
-                    message = null;
-
-                    if (extractor.shouldParseEmbedded(mailMetadata, context)) {
-                        extractor.parseEmbedded(msgStream, xhtml, 
mailMetadata, context, true);
+                    //re-opened over the buffer on rewind rather than cached: 
the
+                    //message is in memory already, a digest must not copy it 
again
+                    try (TikaInputStream msgStream = 
TikaInputStream.get(message::toInputStream,
+                            new TemporaryResources(), null)) {
+                        if (extractor.shouldParseEmbedded(mailMetadata, 
context)) {
+                            extractor.parseEmbedded(msgStream, xhtml, 
mailMetadata, context, true);
+                        }
                     }
 
                     if (tracking) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/java/org/apache/tika/parser/mbox/MboxParserNoTempFileTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/java/org/apache/tika/parser/mbox/MboxParserNoTempFileTest.java
new file mode 100644
index 0000000000..9960bc4804
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/java/org/apache/tika/parser/mbox/MboxParserNoTempFileTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.parser.mbox;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.RewindRecordingExtractor;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * An mbox message is buffered in memory before it is handed over. Rewinding 
the
+ * stream the embedded-document extractor gets -- which digesting does for 
every
+ * embedded document -- must re-read that buffer, not copy it and spill the 
copy
+ * to a temp file. The message is over the 1 MB a cache keeps in memory, so the
+ * difference is observable.
+ */
+public class MboxParserNoTempFileTest {
+
+    private static final int BODY_LENGTH = 2 * 1024 * 1024;
+
+    @Test
+    public void testMessageIsNotSpooled() throws Exception {
+        StringBuilder mbox = new StringBuilder(BODY_LENGTH + 200);
+        mbox.append("From [email protected] Thu Sep  4 10:00:00 2026\n")
+                .append("From: [email protected]\n")
+                .append("Subject: big\n\n");
+        String line = "x".repeat(99) + "\n";
+        while (mbox.length() < BODY_LENGTH) {
+            mbox.append(line);
+        }
+        RewindRecordingExtractor extractor = new RewindRecordingExtractor();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, extractor);
+        try (TikaInputStream tis =
+                
TikaInputStream.get(mbox.toString().getBytes(StandardCharsets.US_ASCII))) {
+            new MboxParser().parse(tis, new DefaultHandler(), new Metadata(), 
context);
+        }
+        //the parser keeps everything but the divider line, so a little under 
the input
+        extractor.assertSawLengthAtLeast(BODY_LENGTH - 100);
+        extractor.assertNothingSpooled();
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/epub/EpubParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/epub/EpubParser.java
index 7428a5b6ae..ad0ad4ee90 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/epub/EpubParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/epub/EpubParser.java
@@ -51,6 +51,7 @@ import org.apache.tika.exception.TikaException;
 import org.apache.tika.exception.WriteLimitReachedException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -184,7 +185,7 @@ public class EpubParser implements Parser {
             return fallbackParseAllHtmlEntries(zipFile, bodyHandler, 
normalizer, metadata, context,
                     "OPF entry missing or unreadable in (possibly truncated) 
container");
         }
-        try (TikaInputStream tis = 
TikaInputStream.get(zipFile.getInputStream(zae))) {
+        try (TikaInputStream tis = entryStream(zipFile, zae)) {
             opf.parse(tis, new DefaultHandler(), metadata, context);
         }
 
@@ -234,7 +235,7 @@ public class EpubParser implements Parser {
                     }
                     zae = zipFile.getEntry(relativePath + hRefMediaPair.href);
                     if (zae != null) {
-                        try (TikaInputStream tis = 
TikaInputStream.get(zipFile.getInputStream(zae))) {
+                        try (TikaInputStream tis = entryStream(zipFile, zae)) {
                             content.parse(tis, bodyHandler, metadata, context);
                             spineParsed++;
                         } catch (SAXException e) {
@@ -345,7 +346,7 @@ public class EpubParser implements Parser {
             if (!zipFile.canReadEntryData(entry)) {
                 continue;
             }
-            try (TikaInputStream tis = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+            try (TikaInputStream tis = entryStream(zipFile, entry)) {
                 content.parse(tis, bodyHandler, metadata, context);
                 parsed++;
             } catch (SAXException e) {
@@ -428,6 +429,11 @@ public class EpubParser implements Parser {
      * @param cover whether this is the publication's cover image, emitted as
      *              the {@link 
TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}
      */
+    /** Re-opened from the zip on rewind rather than cached: the entry is in 
the container already. */
+    private static TikaInputStream entryStream(ZipFile zipFile, 
ZipArchiveEntry entry) {
+        return TikaInputStream.get(() -> zipFile.getInputStream(entry), new 
TemporaryResources(), null);
+    }
+
     private void handleEmbedded(ZipFile zipFile, String relativePath, 
HRefMediaPair hRefMediaPair,
                                 boolean cover,
                                 EmbeddedDocumentExtractor 
embeddedDocumentExtractor,
@@ -456,14 +462,14 @@ public class EpubParser implements Parser {
             return;
         }
 
-        TikaInputStream tis = null;
+        //open once now so a broken entry is recorded here, not mid-parse
         try {
-            tis = TikaInputStream.get(zipFile.getInputStream(ze));
+            zipFile.getInputStream(ze).close();
         } catch (IOException e) {
-            //store this exception in the parent's metadata
             EmbeddedDocumentUtil.recordEmbeddedStreamException(e, 
parentMetadata, context);
             return;
         }
+        TikaInputStream tis = entryStream(zipFile, ze);
 
         xhtml.startElement("div", "class", "embedded");
         try {
@@ -491,7 +497,7 @@ public class EpubParser implements Parser {
         }
         zae = zipFile.getEntry("metadata.xml");
         if (zae != null && zipFile.canReadEntryData(zae)) {
-            try (TikaInputStream tis = 
TikaInputStream.get(zipFile.getInputStream(zae))) {
+            try (TikaInputStream tis = entryStream(zipFile, zae)) {
                 meta.parse(tis, new DefaultHandler(), metadata, context);
             }
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraParser.java
index 4b725006e1..e8033ec742 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraParser.java
@@ -44,6 +44,8 @@ import org.apache.tika.exception.WriteLimitReachedException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.io.BoundedInputStream;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -429,13 +431,15 @@ public class GeoGebraParser implements Parser {
         if (page != null) {
             PageAnchoring.applyPageMetadata(embeddedMetadata, 
Collections.singleton(page));
         }
-        try (TikaInputStream tisZip = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+        //re-opened from the zip on rewind rather than cached or spooled: the 
entry
+        //is in the container already, so detection and a digest re-read it in 
place
+        try (TikaInputStream tisZip = TikaInputStream.get(() -> 
zipFile.getInputStream(entry),
+                new TemporaryResources(), null)) {
             if (type == null) {
-                //spool so the stream can be rewound after detection
-                tisZip.getFile();
+                tisZip.enableRewind(context.get(CacheMemoryBudget.class));
                 MediaType mediaType = EmbeddedDocumentUtil.getDetector(context)
                         .detect(tisZip, embeddedMetadata, context);
-                tisZip.reset();
+                tisZip.rewind();
                 if (mediaType != null) {
                     embeddedMetadata.set(HttpHeaders.CONTENT_TYPE, 
mediaType.toString());
                 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
index 5cc0f37a1c..9c4124b9fe 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
@@ -46,6 +46,7 @@ import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -208,6 +209,11 @@ public class OpenDocumentParser implements Parser {
         return extractMacros;
     }
 
+    /** Re-opened from the zip on rewind rather than cached: the entry is in 
the container already. */
+    private static TikaInputStream entryStream(ZipFile zipFile, 
ZipArchiveEntry entry) {
+        return TikaInputStream.get(() -> zipFile.getInputStream(entry), new 
TemporaryResources(), null);
+    }
+
     private void handleZipFile(ZipFile zipFile, Metadata metadata, 
ParseContext context,
                                EndDocumentShieldingContentHandler handler,
                                EmbeddedDocumentExtractor 
embeddedDocumentExtractor)
@@ -226,7 +232,7 @@ public class OpenDocumentParser implements Parser {
 
         ZipArchiveEntry entry = zipFile.getEntry(MANIFEST_NAME);
         if (entry != null) {
-            try (TikaInputStream tisZip = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+            try (TikaInputStream tisZip = entryStream(zipFile, entry)) {
                 handleZipArchiveEntry(entry, tisZip, metadata, context, 
handler,
                         embeddedDocumentExtractor, picturePages);
             }
@@ -234,7 +240,7 @@ public class OpenDocumentParser implements Parser {
 
         entry = zipFile.getEntry(META_NAME);
         if (entry != null) {
-            try (TikaInputStream tisZip = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+            try (TikaInputStream tisZip = entryStream(zipFile, entry)) {
                 handleZipArchiveEntry(entry, tisZip, metadata, context, 
handler,
                         embeddedDocumentExtractor, picturePages);
             }
@@ -244,7 +250,7 @@ public class OpenDocumentParser implements Parser {
         while (entries.hasMoreElements()) {
             entry = entries.nextElement();
             if (!META_NAME.equals(entry.getName())) {
-                try (TikaInputStream tis = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+                try (TikaInputStream tis = entryStream(zipFile, entry)) {
                     handleZipArchiveEntry(entry, tis, metadata, context, 
handler,
                             embeddedDocumentExtractor, picturePages);
                 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/EmbeddedEntriesNoTempFileTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/EmbeddedEntriesNoTempFileTest.java
new file mode 100644
index 0000000000..e39aaa3de4
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/EmbeddedEntriesNoTempFileTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.parser.odf;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Enumeration;
+import java.util.Random;
+
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
+import org.apache.commons.compress.archivers.zip.ZipFile;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.RewindRecordingExtractor;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.epub.EpubParser;
+
+/**
+ * An entry of an ODF or EPUB container is in the zip already. Rewinding the
+ * stream handed to the embedded-document extractor -- which digesting does for
+ * every embedded document, and which the ODF parser itself does to detect a
+ * picture -- must re-open the entry, not cache a copy and spill it to a temp
+ * file. The payload is over the 1 MB a cache keeps in memory, so the 
difference
+ * is observable; it is built at test time by rewriting one entry of a fixture.
+ */
+public class EmbeddedEntriesNoTempFileTest extends TikaTest {
+
+    private static final int PAYLOAD_LENGTH = 2 * 1024 * 1024;
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    public void testOdfPictureIsNotSpooled() throws Exception {
+        Path odt = withEntryReplaced("testODTEmbeddedImageLink.odt",
+                "Pictures/10000201000001240000006457F5B1D1243E0671.png", 
"picture.odt");
+        RewindRecordingExtractor extractor = parse(odt, new 
OpenDocumentParser());
+        extractor.assertSawLength(PAYLOAD_LENGTH);
+        extractor.assertNothingSpooled();
+    }
+
+    @Test
+    public void testEpubResourceIsNotSpooled() throws Exception {
+        Path epub = withEntryReplaced("testEPUB.epub", "OPS/CoverDesign.jpg", 
"cover.epub");
+        RewindRecordingExtractor extractor = parse(epub, new EpubParser());
+        extractor.assertSawLength(PAYLOAD_LENGTH);
+        extractor.assertNothingSpooled();
+    }
+
+    private RewindRecordingExtractor parse(Path file, Parser parser) throws 
Exception {
+        RewindRecordingExtractor extractor = new RewindRecordingExtractor();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, extractor);
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = TikaInputStream.get(file, metadata)) {
+            parser.parse(tis, new DefaultHandler(), metadata, context);
+        }
+        return extractor;
+    }
+
+    /**
+     * A copy of the fixture with one entry's bytes replaced by the payload; 
every
+     * other entry is copied raw, so the stored {@code mimetype} entry stays 
stored.
+     */
+    private Path withEntryReplaced(String fixture, String entryName, String 
name)
+            throws IOException {
+        Path original = tempDir.resolve("original-" + name);
+        try (InputStream is = getResourceAsStream("/test-documents/" + 
fixture)) {
+            Files.copy(is, original);
+        }
+        Path copy = tempDir.resolve(name);
+        boolean replaced = false;
+        try (ZipFile in = ZipFile.builder().setPath(original).get();
+                ZipArchiveOutputStream out = new 
ZipArchiveOutputStream(copy.toFile())) {
+            Enumeration<ZipArchiveEntry> entries = in.getEntries();
+            while (entries.hasMoreElements()) {
+                ZipArchiveEntry entry = entries.nextElement();
+                if (entry.getName().equals(entryName)) {
+                    ZipArchiveEntry big = new ZipArchiveEntry(entryName);
+                    big.setMethod(ZipArchiveEntry.DEFLATED);
+                    out.putArchiveEntry(big);
+                    out.write(payload());
+                    out.closeArchiveEntry();
+                    replaced = true;
+                } else {
+                    try (InputStream raw = in.getRawInputStream(entry)) {
+                        out.addRawArchiveEntry(entry, raw);
+                    }
+                }
+            }
+        }
+        if (!replaced) {
+            throw new IOException(entryName + " is not in " + fixture);
+        }
+        return copy;
+    }
+
+    /** Incompressible filler, so DEFLATE keeps it at full size. */
+    private static byte[] payload() {
+        byte[] bytes = new byte[PAYLOAD_LENGTH];
+        new Random(4878).nextBytes(bytes);
+        return bytes;
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-webarchive-module/src/main/java/org/apache/tika/parser/wacz/WACZParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-webarchive-module/src/main/java/org/apache/tika/parser/wacz/WACZParser.java
index e165b1c74e..fd96a6a33f 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-webarchive-module/src/main/java/org/apache/tika/parser/wacz/WACZParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-webarchive-module/src/main/java/org/apache/tika/parser/wacz/WACZParser.java
@@ -37,6 +37,7 @@ import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -140,6 +141,11 @@ public class WACZParser implements Parser {
         }
     }
 
+    /** Re-opened from the zip on rewind rather than cached: the entry is in 
the container already. */
+    private static TikaInputStream entryStream(ZipFile zipFile, 
ZipArchiveEntry entry) {
+        return TikaInputStream.get(() -> zipFile.getInputStream(entry), new 
TemporaryResources(), null);
+    }
+
     private void processZip(ZipFile zip, XHTMLContentHandler xhtml, Metadata 
metadata,
                             EmbeddedDocumentExtractor ex, ParseContext 
context) throws IOException, SAXException {
 
@@ -149,12 +155,14 @@ public class WACZParser implements Parser {
             String name = zae.getName();
             if (name.startsWith("archive/")) {
                 name = name.substring(8);
-                processWARC(TikaInputStream.get(zip.getInputStream(zae)), zae, 
name, xhtml,
-                        metadata, ex, context);
+                try (TikaInputStream tis = entryStream(zip, zae)) {
+                    processWARC(tis, zae, name, xhtml, metadata, ex, context);
+                }
             } else if ("datapackage.json".equals(name)) {
                 //no-op
-                
processDataPackage(TikaInputStream.get(zip.getInputStream(zae)), zae, xhtml,
-                        metadata);
+                try (TikaInputStream tis = entryStream(zip, zae)) {
+                    processDataPackage(tis, zae, xhtml, metadata);
+                }
             }
         }
     }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xml-module/src/main/java/org/apache/tika/parser/xliff/XLZParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xml-module/src/main/java/org/apache/tika/parser/xliff/XLZParser.java
index 8d66c937a9..3ff0445666 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xml-module/src/main/java/org/apache/tika/parser/xliff/XLZParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xml-module/src/main/java/org/apache/tika/parser/xliff/XLZParser.java
@@ -30,6 +30,7 @@ import org.xml.sax.SAXException;
 
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.mime.MediaType;
@@ -137,7 +138,8 @@ public class XLZParser implements Parser {
         while (entries.hasMoreElements()) {
             ZipEntry entry = entries.nextElement();
             if (entry.getName().contains(XLF)) {
-                try (TikaInputStream tisZip = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+                try (TikaInputStream tisZip = TikaInputStream.get(
+                        () -> zipFile.getInputStream(entry), new 
TemporaryResources(), null)) {
                     xliffParser.parse(tisZip, handler, metadata, context);
                 }
             }

Reply via email to