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 81f2eb01d6 TIKA-4828 -- improve caching while digesting embedded files 
(#3042)
81f2eb01d6 is described below

commit 81f2eb01d696005f3cff5b61e7778f43da375dab
Author: Tim Allison <[email protected]>
AuthorDate: Fri Aug 21 19:48:24 2026 -0400

    TIKA-4828 -- improve caching while digesting embedded files (#3042)
---
 CHANGES.txt                                        |  13 +
 docs/modules/ROOT/pages/pipes/configuration.adoc   |  22 +-
 .../java/org/apache/tika/digest/DigestHelper.java  |   3 +-
 .../apache/tika/digest/InputStreamDigester.java    |   8 +-
 .../java/org/apache/tika/io/ByteArraySource.java   |   9 +-
 .../java/org/apache/tika/io/CacheMemoryBudget.java |  86 ++++++
 .../org/apache/tika/io/CachingInputStream.java     |  34 +++
 .../java/org/apache/tika/io/CachingSource.java     |  40 ++-
 .../main/java/org/apache/tika/io/FileSource.java   |  10 +-
 .../apache/tika/io/MemorySeekableByteChannel.java  | 115 ++++++++
 .../java/org/apache/tika/io/ReopenableSource.java  | 319 +++++++++++++++++++++
 .../main/java/org/apache/tika/io/StreamCache.java  | 114 ++++++--
 .../java/org/apache/tika/io/TikaInputSource.java   |  18 +-
 .../java/org/apache/tika/io/TikaInputStream.java   |  70 ++++-
 .../parser/multiple/AbstractMultipleParser.java    |   3 +-
 .../org/apache/tika/io/CacheMemoryBudgetTest.java  | 108 +++++++
 .../tika/io/MemorySeekableByteChannelTest.java     | 166 +++++++++++
 .../org/apache/tika/io/ReopenableSourceTest.java   | 303 +++++++++++++++++++
 .../org/apache/tika/io/StreamCacheBudgetTest.java  | 158 ++++++++++
 .../apache/tika/io/TikaInputStreamChannelTest.java | 159 ++++++++++
 .../org/apache/tika/io/TikaInputStreamTest.java    |  10 +-
 .../org/apache/tika/parser/crypto/Pkcs7Parser.java |   6 +-
 .../parser/microsoft/onenote/OneNoteParser.java    |   8 +-
 .../org/apache/tika/parser/epub/EpubParser.java    |   3 +-
 .../apache/tika/parser/odf/OpenDocumentParser.java |   6 +-
 .../apache/tika/parser/pkg/CompressorParser.java   |  17 +-
 .../org/apache/tika/parser/pkg/SevenZParser.java   |  11 +-
 .../java/org/apache/tika/parser/pkg/ZipParser.java |  14 +-
 .../tika/parser/pkg/ZipEmbeddedDigestTest.java     | 162 +++++++++++
 .../detect/zip/DefaultZipContainerDetector.java    |  29 +-
 .../org/apache/tika/zip/utils/ZipFileHelper.java   |  51 ++++
 .../org/apache/tika/zip/utils/ZipSalvager.java     |   9 +-
 .../tika/pipes/core/server/ParseHandler.java       |   4 +-
 .../apache/tika/pipes/core/server/PipesServer.java |  47 +++
 .../pipes/core/server/SharedServerResources.java   |   1 +
 .../core/server/CacheMemoryBudgetSeedingTest.java  |  45 +++
 36 files changed, 2100 insertions(+), 81 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 9e3a699fff..5dde33b1b4 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,18 @@
 Release 4.1.0 - ???
 
+   * Digesting embedded documents no longer buffers each embedded object to a
+     temp file. Zip entries are re-read from the parent archive on rewind, and
+     a new process-wide CacheMemoryBudget (seeded by the pipes forked server;
+     default 256MB, clamped to a quarter of the fork's heap; tunable via
+     -Dtika.pipes.cacheMemoryBudgetBytes in the config's forkedJvmArgs, <=0
+     disables) lets embedded objects stay in memory past the per-object 1MB
+     threshold. New public API on TikaInputStream: get(IOSupplier,...),
+     enableRewind(CacheMemoryBudget), getSeekableByteChannel(). Zip/7z/epub/odf
+     parsing and zip container detection now read through seekable channels, so
+     after detection/parsing a TikaInputStream may no longer be file-backed
+     (hasFile() false); getPath()/getFile() still work and spool on demand
+     (TIKA-4828).
+     
    * Pipes now carries the caller-supplied Content-Type across the worker's
      fresh-metadata boundary as a soft detection hint, so every forked-parse
      endpoint (/tika, /meta, /rmeta, /unpack, /async, /pipes, plus tika-grpc
diff --git a/docs/modules/ROOT/pages/pipes/configuration.adoc 
b/docs/modules/ROOT/pages/pipes/configuration.adoc
index 763f2a5612..02a309661e 100644
--- a/docs/modules/ROOT/pages/pipes/configuration.adoc
+++ b/docs/modules/ROOT/pages/pipes/configuration.adoc
@@ -45,7 +45,7 @@ how many forked JVMs to run, timeouts, memory management, and 
parse behavior.
 
 |`forkedJvmArgs`
 |`[]`
-|JVM arguments for forked processes (e.g., `["-Xmx512m", "-Xms256m"]`). Unless 
you supply your own, Tika auto-injects `-XX:ActiveProcessorCount` to right-size 
each fork's GC and JIT thread pools, and `-XX:MaxRAMPercentage` to keep the 
forks' combined heap ceiling under the host's memory; see 
xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing].
+|JVM arguments for forked processes (e.g., `["-Xmx512m", "-Xms256m"]`). Unless 
you supply your own, Tika auto-injects `-XX:ActiveProcessorCount` to right-size 
each fork's GC and JIT thread pools, and `-XX:MaxRAMPercentage` to keep the 
forks' combined heap ceiling under the host's memory; see 
xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing]. This is also where 
the fork's system properties go, e.g. `-Dtika.pipes.cacheMemoryBudgetBytes` 
below -- setting them on the parent JVM has  [...]
 
 |`javaPath`
 |`java`
@@ -66,6 +66,26 @@ cannot, and its subdirectories survive. On a RAM-backed 
filesystem those leaks c
 rather than disk, and `/dev/shm` is commonly sized at half of RAM -- so if you 
point
 `tempDirectory` at one, sweep it on service start.
 
+=== Embedded-object cache memory budget
+
+Each fork holds a process-wide in-memory budget for stream caching (chiefly 
the rewind
+buffers used when digesting embedded documents), so small embedded objects 
stay in RAM
+instead of spilling to a temp file at the per-object 1MB threshold. The 
default is 256MB per
+fork, clamped to a quarter of the fork's max heap; the effective value is 
logged at fork
+startup. Tune it with a system property in `forkedJvmArgs` (plain bytes, no 
unit suffix;
+`<=0` disables the budget and restores the per-object threshold):
+
+[source,json]
+----
+"forkedJvmArgs": ["-Xmx1g", "-Dtika.pipes.cacheMemoryBudgetBytes=134217728"]
+----
+
+Size `-Xmx` with the budget in mind: the budget is additional heap the fork 
may use on top
+of its parsing working set, and in per-client mode every fork holds its own 
budget
+(`numClients` x budget in aggregate). In shared-server mode all concurrent 
parses in the
+single forked server share one budget, so each in-flight document gets a 
smaller slice of
+the same value.
+
 == Timeouts
 
 See also xref:pipes/timeouts.adoc[Timeouts] for the full timeout model.
diff --git a/tika-core/src/main/java/org/apache/tika/digest/DigestHelper.java 
b/tika-core/src/main/java/org/apache/tika/digest/DigestHelper.java
index a5f751b1ae..359ebf9f27 100644
--- a/tika-core/src/main/java/org/apache/tika/digest/DigestHelper.java
+++ b/tika-core/src/main/java/org/apache/tika/digest/DigestHelper.java
@@ -23,6 +23,7 @@ import java.nio.file.Path;
 
 import org.apache.tika.extractor.DefaultEmbeddedStreamTranslator;
 import org.apache.tika.extractor.EmbeddedStreamTranslator;
+import org.apache.tika.io.CacheMemoryBudget;
 import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
@@ -83,7 +84,7 @@ public class DigestHelper {
         // The translator consumes `tis` (e.g. OLE2), so enableRewind() before 
and rewind()
         // after -- otherwise the caller would see an exhausted stream.
         if (EMBEDDED_STREAM_TRANSLATOR.shouldTranslate(tis, metadata)) {
-            tis.enableRewind();
+            tis.enableRewind(context.get(CacheMemoryBudget.class));
             try (TemporaryResources tmp = new TemporaryResources()) {
                 Path tmpBytes = tmp.createTempFile();
                 try (OutputStream os = Files.newOutputStream(tmpBytes)) {
diff --git 
a/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java 
b/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java
index 56ee7ea21e..00941602d3 100644
--- a/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java
+++ b/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java
@@ -23,6 +23,7 @@ import java.security.Provider;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
+import org.apache.tika.io.CacheMemoryBudget;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -110,13 +111,16 @@ public class InputStreamDigester implements Digester {
      *
      * @param tis          TikaInputStream to digest
      * @param metadata     metadata in which to store the digest information
-     * @param parseContext ParseContext -- not actually used yet, but there 
for future expansion
+     * @param parseContext ParseContext, which may carry a {@link 
CacheMemoryBudget}
      * @throws IOException on IO problem or IllegalArgumentException if 
algorithm couldn't be found
      */
     @Override
     public void digest(TikaInputStream tis, Metadata metadata, ParseContext 
parseContext)
             throws IOException {
-        tis.enableRewind();
+        // Bridge the shared budget from ParseContext; TikaInputStream never 
sees ParseContext
+        CacheMemoryBudget budget =
+                (parseContext == null) ? null : 
parseContext.get(CacheMemoryBudget.class);
+        tis.enableRewind(budget);
 
         MessageDigest messageDigest = newMessageDigest();
         byte[] buffer = new byte[8192];
diff --git a/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java 
b/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java
index d2d3bbd61a..72c94f728e 100644
--- a/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java
+++ b/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java
@@ -19,6 +19,7 @@ package org.apache.tika.io;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
@@ -120,10 +121,16 @@ class ByteArraySource extends InputStream implements 
TikaInputSource {
     }
 
     @Override
-    public void enableRewind() throws IOException {
+    public void enableRewind(CacheMemoryBudget budget) throws IOException {
         // No-op: byte array is always rewindable
     }
 
+    @Override
+    public SeekableByteChannel getSeekableByteChannel() {
+        // The array stays valid even after a getPath() spill copy
+        return new MemorySeekableByteChannel(data, length);
+    }
+
     // Mark/reset support using position tracking
     private int markPosition = -1;
 
diff --git a/tika-core/src/main/java/org/apache/tika/io/CacheMemoryBudget.java 
b/tika-core/src/main/java/org/apache/tika/io/CacheMemoryBudget.java
new file mode 100644
index 0000000000..58af58c33c
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/io/CacheMemoryBudget.java
@@ -0,0 +1,86 @@
+/*
+ * 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.io;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A shared, bounded memory budget for in-memory stream caching.
+ * <p>
+ * When present, stream caches hold content in memory past the per-object 
threshold by
+ * reserving buffer <em>capacity</em> against this budget, spilling to a temp 
file when a
+ * reservation fails. Content up to the per-object threshold is not accounted 
here. Place a
+ * single, process-wide instance in the {@link 
org.apache.tika.parser.ParseContext}; it is
+ * bridged to the IO layer via {@link 
TikaInputStream#enableRewind(CacheMemoryBudget)}.
+ * <p>
+ * Thread-safe; a single instance may be shared across concurrent parses.
+ */
+public final class CacheMemoryBudget {
+
+    private final long maxBytes;
+    private final AtomicLong reserved = new AtomicLong();
+
+    /**
+     * @param maxBytes maximum total bytes that may be held in memory across 
all caches sharing
+     *                 this budget; must be positive (to disable budgeting, 
pass no budget at all)
+     */
+    public CacheMemoryBudget(long maxBytes) {
+        if (maxBytes <= 0) {
+            throw new IllegalArgumentException("maxBytes must be > 0: " + 
maxBytes);
+        }
+        this.maxBytes = maxBytes;
+    }
+
+    /**
+     * Attempts to reserve {@code n} bytes. All-or-nothing: either the full 
amount is reserved
+     * (return {@code n}) or nothing is (return {@code 0}, signalling the 
caller to spill).
+     *
+     * @param n bytes requested
+     * @return {@code n} if reserved, else {@code 0}
+     */
+    public long tryReserve(long n) {
+        if (n <= 0) {
+            return 0;
+        }
+        while (true) {
+            long cur = reserved.get();
+            if (cur + n > maxBytes) {
+                return 0;
+            }
+            if (reserved.compareAndSet(cur, cur + n)) {
+                return n;
+            }
+        }
+    }
+
+    /**
+     * Releases {@code n} previously-reserved bytes back to the budget.
+     */
+    public void release(long n) {
+        if (n > 0) {
+            reserved.addAndGet(-n);
+        }
+    }
+
+    public long getMaxBytes() {
+        return maxBytes;
+    }
+
+    public long getReservedBytes() {
+        return reserved.get();
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/io/CachingInputStream.java 
b/tika-core/src/main/java/org/apache/tika/io/CachingInputStream.java
index c17537ba22..ff97c31485 100644
--- a/tika-core/src/main/java/org/apache/tika/io/CachingInputStream.java
+++ b/tika-core/src/main/java/org/apache/tika/io/CachingInputStream.java
@@ -18,7 +18,10 @@ package org.apache.tika.io;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
 
 /**
  * Package-private InputStream wrapper that caches all bytes read,
@@ -42,6 +45,37 @@ class CachingInputStream extends InputStream {
         this.sourceExhausted = false;
     }
 
+    /**
+     * Returns a read-only random-access channel over the full content, 
draining any unread
+     * source bytes into the cache first; in-memory when the content fit, a 
file channel when
+     * it spilled. Does not change the logical read position.
+     */
+    SeekableByteChannel getSeekableByteChannel() throws IOException {
+        drainSourceToCache();
+        SeekableByteChannel channel = cache.getInMemorySeekableByteChannel();
+        if (channel != null) {
+            return channel;
+        }
+        // The drain overflowed the memory budget/threshold -> serve from the 
spill file.
+        return FileChannel.open(cache.toFile(), StandardOpenOption.READ);
+    }
+
+    /**
+     * Drains any remaining source bytes into the cache without changing the 
logical read
+     * position.
+     */
+    private void drainSourceToCache() throws IOException {
+        if (sourceExhausted) {
+            return;
+        }
+        byte[] buffer = new byte[8192];
+        int n;
+        while ((n = source.read(buffer)) != -1) {
+            cache.append(buffer, 0, n);
+        }
+        sourceExhausted = true;
+    }
+
     @Override
     public int read() throws IOException {
         if (position < cache.size()) {
diff --git a/tika-core/src/main/java/org/apache/tika/io/CachingSource.java 
b/tika-core/src/main/java/org/apache/tika/io/CachingSource.java
index 6ed283ee8f..70d4fc6a63 100644
--- a/tika-core/src/main/java/org/apache/tika/io/CachingSource.java
+++ b/tika-core/src/main/java/org/apache/tika/io/CachingSource.java
@@ -20,8 +20,11 @@ import java.io.BufferedInputStream;
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
 
 import org.apache.commons.io.IOUtils;
 
@@ -33,9 +36,9 @@ import org.apache.tika.utils.StringUtils;
  * Input source that wraps a raw InputStream with optional caching.
  * <p>
  * Starts in passthrough mode using {@link BufferedInputStream} for basic
- * mark/reset support. When {@link #enableRewind()} is called (at position 0),
- * switches to caching mode using {@link CachingInputStream} which enables
- * full rewind/seek capability.
+ * mark/reset support. When {@link #enableRewind(CacheMemoryBudget)} is called
+ * (at position 0), switches to caching mode using {@link CachingInputStream}
+ * which enables full rewind/seek capability.
  * <p>
  * If caching is not enabled, {@link #seekTo(long)} will fail for any position
  * other than the current position.
@@ -186,7 +189,7 @@ class CachingSource extends InputStream implements 
TikaInputSource {
     }
 
     @Override
-    public void enableRewind() throws IOException {
+    public void enableRewind(CacheMemoryBudget budget) throws IOException {
         // Already in caching or file mode - no-op
         if (cachingStream != null || fileStream != null) {
             return;
@@ -199,11 +202,36 @@ class CachingSource extends InputStream implements 
TikaInputSource {
         }
 
         // Switch to caching mode
-        StreamCache cache = new StreamCache(tmp, suffix);
+        StreamCache cache = new StreamCache(tmp, suffix, budget);
         cachingStream = new CachingInputStream(passthroughStream, cache);
         passthroughStream = null;
     }
 
+    @Override
+    public SeekableByteChannel getSeekableByteChannel() throws IOException {
+        if (spilledPath != null) {
+            return FileChannel.open(spilledPath, StandardOpenOption.READ);
+        }
+        // If still in passthrough mode, switch to caching first (same rule as 
getPath)
+        if (cachingStream == null) {
+            if (passthroughPosition != 0) {
+                throw new IOException(
+                        "Cannot create seekable view: position is " + 
passthroughPosition +
+                                ", must be 0. Call enableRewind() before 
reading.");
+            }
+            enableRewind(null);
+        }
+        SeekableByteChannel channel = cachingStream.getSeekableByteChannel();
+        // Record the drained length like getPath() does (feeds 
SecureContentHandler's
+        // zip-bomb ratio)
+        length = channel.size();
+        if (metadata != null &&
+                StringUtils.isBlank(metadata.get(HttpHeaders.CONTENT_LENGTH))) 
{
+            metadata.set(HttpHeaders.CONTENT_LENGTH, Long.toString(length));
+        }
+        return channel;
+    }
+
     @Override
     public void seekTo(long position) throws IOException {
         if (fileStream != null) {
@@ -245,7 +273,7 @@ class CachingSource extends InputStream implements 
TikaInputSource {
                             "Cannot spill to file: position is " + 
passthroughPosition +
                                     ", must be 0. Call enableRewind() before 
reading if you need file access.");
                 }
-                enableRewind();
+                enableRewind(null);
             }
 
             // Spill to file and switch to file-backed mode
diff --git a/tika-core/src/main/java/org/apache/tika/io/FileSource.java 
b/tika-core/src/main/java/org/apache/tika/io/FileSource.java
index 143bb2d562..fbd06d7a0d 100644
--- a/tika-core/src/main/java/org/apache/tika/io/FileSource.java
+++ b/tika-core/src/main/java/org/apache/tika/io/FileSource.java
@@ -19,8 +19,11 @@ package org.apache.tika.io;
 import java.io.BufferedInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
 
 import org.apache.commons.io.IOUtils;
 
@@ -120,10 +123,15 @@ class FileSource extends InputStream implements 
TikaInputSource {
     }
 
     @Override
-    public void enableRewind() throws IOException {
+    public void enableRewind(CacheMemoryBudget budget) throws IOException {
         // No-op: file is always rewindable
     }
 
+    @Override
+    public SeekableByteChannel getSeekableByteChannel() throws IOException {
+        return FileChannel.open(path, StandardOpenOption.READ);
+    }
+
     // Mark/reset support using seekTo
     private long markPosition = -1;
 
diff --git 
a/tika-core/src/main/java/org/apache/tika/io/MemorySeekableByteChannel.java 
b/tika-core/src/main/java/org/apache/tika/io/MemorySeekableByteChannel.java
new file mode 100644
index 0000000000..71d71d93ed
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/io/MemorySeekableByteChannel.java
@@ -0,0 +1,115 @@
+/*
+ * 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.io;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.NonWritableChannelException;
+import java.nio.channels.SeekableByteChannel;
+
+/**
+ * Read-only {@link SeekableByteChannel} over an in-memory byte array. Wraps 
the array directly
+ * (no copy); callers must not mutate the backing array while the channel is 
in use.
+ */
+class MemorySeekableByteChannel implements SeekableByteChannel {
+
+    private final byte[] data;
+    private final int length;
+    private final Runnable onClose;
+    private long position;
+    private boolean open = true;
+
+    MemorySeekableByteChannel(byte[] data, int length) {
+        this(data, length, null);
+    }
+
+    /** {@code onClose} runs exactly once, on the first {@link #close()} -- 
used by sources
+     *  to unpin a budget reservation held while this channel references their 
buffer. */
+    MemorySeekableByteChannel(byte[] data, int length, Runnable onClose) {
+        this.data = data;
+        this.length = length;
+        this.onClose = onClose;
+    }
+
+    @Override
+    public int read(ByteBuffer dst) throws IOException {
+        ensureOpen();
+        if (position >= length) {
+            return -1;
+        }
+        int pos = (int) position;
+        int n = Math.min(dst.remaining(), length - pos);
+        dst.put(data, pos, n);
+        position += n;
+        return n;
+    }
+
+    @Override
+    public int write(ByteBuffer src) {
+        throw new NonWritableChannelException();
+    }
+
+    @Override
+    public long position() throws IOException {
+        ensureOpen();
+        return position;
+    }
+
+    @Override
+    public SeekableByteChannel position(long newPosition) throws IOException {
+        ensureOpen();
+        if (newPosition < 0) {
+            throw new IllegalArgumentException("negative position: " + 
newPosition);
+        }
+        // Setting a position beyond size is legal per SeekableByteChannel; 
reads return EOF
+        position = newPosition;
+        return this;
+    }
+
+    @Override
+    public long size() throws IOException {
+        ensureOpen();
+        return length;
+    }
+
+    @Override
+    public SeekableByteChannel truncate(long size) {
+        throw new NonWritableChannelException();
+    }
+
+    @Override
+    public boolean isOpen() {
+        return open;
+    }
+
+    @Override
+    public void close() {
+        if (open) {
+            open = false;
+            if (onClose != null) {
+                onClose.run();
+            }
+        }
+    }
+
+    private void ensureOpen() throws IOException {
+        if (!open) {
+            throw new ClosedChannelException();
+        }
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java 
b/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java
new file mode 100644
index 0000000000..f758f1a295
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java
@@ -0,0 +1,319 @@
+/*
+ * 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.io;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.function.IOSupplier;
+
+/**
+ * Input source backed by a re-openable stream supplier (e.g. an entry in a
+ * random-access {@code ZipFile}, which can be re-opened via
+ * {@code zipFile.getInputStream(entry)}).
+ * <p>
+ * Because the underlying content can be re-read on demand, rewinding simply
+ * re-opens the source, avoiding the memory-then-disk caching that
+ * {@link CachingSource} performs for one-shot streams. A temp file is created
+ * only if {@link #getPath} is called or {@link #getSeekableByteChannel()} is
+ * asked for content that does not fit in memory.
+ */
+class ReopenableSource extends InputStream implements TikaInputSource {
+
+    // Per-object bytes that may be buffered in memory without a budget 
reservation;
+    // matches StreamCache's default per-object threshold.
+    private static final int IN_MEMORY_FLOOR = 1024 * 1024;
+
+    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
+
+    private final IOSupplier<InputStream> opener;
+    private final TemporaryResources tmp;
+    private final String suffix;
+    private long length;
+
+    private InputStream currentStream; // lazily opened
+    private long position;
+    private Path spilledPath;
+    private long markPosition = -1;
+
+    private CacheMemoryBudget budget;
+    // Full content retained after an in-memory drain, with its budget 
reservation,
+    // so repeated channel requests don't re-read (re-decompress) the entry.
+    private byte[] retainedBuffer;
+    private int retainedLength;
+    private long retainedReservation;
+    private int channelPins;
+    private boolean closed;
+
+    ReopenableSource(IOSupplier<InputStream> opener, TemporaryResources tmp, 
long length,
+                     String suffix) {
+        this.opener = opener;
+        this.tmp = tmp;
+        this.length = length;
+        this.suffix = suffix;
+        this.position = 0;
+    }
+
+    private void ensureOpen() throws IOException {
+        if (currentStream == null) {
+            currentStream = new BufferedInputStream(
+                    spilledPath != null ? Files.newInputStream(spilledPath) : 
opener.get());
+        }
+    }
+
+    @Override
+    public int read() throws IOException {
+        ensureOpen();
+        int b = currentStream.read();
+        if (b != -1) {
+            position++;
+        }
+        return b;
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+        ensureOpen();
+        int n = currentStream.read(b, off, len);
+        if (n > 0) {
+            position += n;
+        }
+        return n;
+    }
+
+    @Override
+    public long skip(long n) throws IOException {
+        ensureOpen();
+        long skipped = IOUtils.skip(currentStream, n);
+        position += skipped;
+        return skipped;
+    }
+
+    @Override
+    public int available() throws IOException {
+        ensureOpen();
+        return currentStream.available();
+    }
+
+    @Override
+    public void seekTo(long newPosition) throws IOException {
+        if (newPosition < 0) {
+            throw new IOException("Cannot seek to negative position: " + 
newPosition);
+        }
+        if (currentStream != null) {
+            currentStream.close();
+        }
+        currentStream = new BufferedInputStream(
+                spilledPath != null ? Files.newInputStream(spilledPath) : 
opener.get());
+        if (newPosition > 0) {
+            IOUtils.skipFully(currentStream, newPosition);
+        }
+        this.position = newPosition;
+    }
+
+    @Override
+    public boolean hasPath() {
+        return spilledPath != null;
+    }
+
+    @Override
+    public Path getPath(String suffix) throws IOException {
+        if (spilledPath == null) {
+            Path p = tmp.createTempFile(suffix == null ? this.suffix : suffix);
+            try (OutputStream out = Files.newOutputStream(p)) {
+                if (retainedBuffer != null) {
+                    out.write(retainedBuffer, 0, retainedLength);
+                } else {
+                    try (InputStream in = opener.get()) {
+                        IOUtils.copy(in, out);
+                    }
+                }
+            }
+            spilledPath = p;
+            // The spooled size is ground truth, even over a lying declared 
length
+            length = Files.size(p);
+        }
+        return spilledPath;
+    }
+
+    @Override
+    public long getLength() {
+        return length;
+    }
+
+    @Override
+    public void enableRewind(CacheMemoryBudget budget) throws IOException {
+        if (position != 0) {
+            throw new IOException("Cannot enable rewind: position is " + 
position +
+                    ", must be 0. Call enableRewind() before reading.");
+        }
+        // No caching needed to rewind (the source re-opens); the budget is 
kept for
+        // on-demand in-memory buffering in getSeekableByteChannel().
+        if (this.budget == null) {
+            this.budget = budget;
+        }
+    }
+
+    @Override
+    public SeekableByteChannel getSeekableByteChannel() throws IOException {
+        if (retainedBuffer != null) {
+            return retainedChannel();
+        }
+        if (spilledPath == null && tryBufferInMemory()) {
+            return retainedChannel();
+        }
+        return FileChannel.open(getPath(null), StandardOpenOption.READ);
+    }
+
+    /** Channels pin the retained buffer: the reservation is released only 
once this source
+     *  is closed AND no handed-out channel still references the array. */
+    private SeekableByteChannel retainedChannel() {
+        channelPins++;
+        return new MemorySeekableByteChannel(retainedBuffer, retainedLength, 
() -> {
+            channelPins--;
+            maybeReleaseRetained();
+        });
+    }
+
+    private void maybeReleaseRetained() {
+        if (closed && channelPins == 0 && retainedReservation > 0) {
+            budget.release(retainedReservation);
+            retainedReservation = 0;
+        }
+    }
+
+    /**
+     * Drains a fresh stream into memory if it fits within the per-object 
floor plus what
+     * can be reserved from the shared budget, retaining the buffer (and its 
reservation)
+     * until {@link #close()}. The declared length is a sizing hint only -- it 
can lie, so
+     * the cap is enforced during the read. Does not disturb this source's 
read position.
+     */
+    private boolean tryBufferInMemory() throws IOException {
+        if (length > MAX_ARRAY_SIZE || (length > IN_MEMORY_FLOOR && budget == 
null)) {
+            return false;
+        }
+        long reservedHere = 0;
+        // Reservation invariant: reservedHere == max(0, data.length - 
IN_MEMORY_FLOOR)
+        if (length > IN_MEMORY_FLOOR) {
+            long extra = length - IN_MEMORY_FLOOR;
+            if (budget.tryReserve(extra) != extra) {
+                return false;
+            }
+            reservedHere = extra;
+        }
+        byte[] data = new byte[length > 0 ? (int) length : 8192];
+        int total = 0;
+        boolean fits = false;
+        try (InputStream in = opener.get()) {
+            while (true) {
+                if (total == data.length) {
+                    int peek = in.read();
+                    if (peek == -1) {
+                        fits = true;
+                        break;
+                    }
+                    long newCapacity =
+                            Math.min(MAX_ARRAY_SIZE, Math.max((long) 
data.length * 2, 8192));
+                    if (newCapacity > IN_MEMORY_FLOOR) {
+                        if (budget == null) {
+                            newCapacity = IN_MEMORY_FLOOR;
+                        } else {
+                            long delta = newCapacity - Math.max(data.length, 
IN_MEMORY_FLOOR);
+                            if (delta > 0) {
+                                if (budget.tryReserve(delta) != delta) {
+                                    break;
+                                }
+                                reservedHere += delta;
+                            }
+                        }
+                    }
+                    if (newCapacity <= data.length) {
+                        break;
+                    }
+                    data = Arrays.copyOf(data, (int) newCapacity);
+                    data[total++] = (byte) peek;
+                    continue;
+                }
+                int r = in.read(data, total, data.length - total);
+                if (r == -1) {
+                    fits = true;
+                    break;
+                }
+                total += r;
+            }
+        } finally {
+            if (!fits && reservedHere > 0) {
+                budget.release(reservedHere);
+            }
+        }
+        if (!fits) {
+            return false;
+        }
+        // Trim over-allocation from a lying declared length, releasing the 
excess reservation
+        if (data.length - total > 8192) {
+            data = Arrays.copyOf(data, Math.max(total, 1));
+            long target = Math.max(0, (long) data.length - IN_MEMORY_FLOOR);
+            if (reservedHere > target) {
+                budget.release(reservedHere - target);
+                reservedHere = target;
+            }
+        }
+        retainedBuffer = data;
+        retainedLength = total;
+        retainedReservation = reservedHere;
+        // The full read is ground truth, even over a lying declared length
+        length = total;
+        return true;
+    }
+
+    @Override
+    public void close() throws IOException {
+        closed = true;
+        retainedBuffer = null;
+        maybeReleaseRetained();
+        if (currentStream != null) {
+            currentStream.close();
+        }
+    }
+
+    @Override
+    public synchronized void mark(int readlimit) {
+        markPosition = position;
+    }
+
+    @Override
+    public synchronized void reset() throws IOException {
+        if (markPosition < 0) {
+            throw new IOException("Mark not set");
+        }
+        seekTo(markPosition);
+    }
+
+    @Override
+    public boolean markSupported() {
+        return true;
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/io/StreamCache.java 
b/tika-core/src/main/java/org/apache/tika/io/StreamCache.java
index 9da3d69991..d3e4aaae7d 100644
--- a/tika-core/src/main/java/org/apache/tika/io/StreamCache.java
+++ b/tika-core/src/main/java/org/apache/tika/io/StreamCache.java
@@ -22,6 +22,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.RandomAccessFile;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
@@ -33,9 +34,20 @@ class StreamCache implements Closeable {
 
     private static final int DEFAULT_MEMORY_THRESHOLD = 1024 * 1024; // 1MB
 
+    // Max size of the in-memory byte[] (a single JVM array); with a budget we 
grow past the
+    // per-object threshold but never past this.
+    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
+
     private final int memoryThreshold;
     private final TemporaryResources tmp;
 
+    // Optional shared memory budget; when non-null it governs the 
memory-vs-spill decision
+    // instead of the fixed per-object memoryThreshold.
+    private final CacheMemoryBudget budget;
+    private long reserved;
+    // In-memory channels handed out that still reference memoryBuffer (see 
maybeReleaseReserved)
+    private int channelPins;
+
     // Memory storage (null after spill)
     private byte[] memoryBuffer;
     private int memorySize;
@@ -50,19 +62,19 @@ class StreamCache implements Closeable {
 
     private boolean closed;
 
-    StreamCache(TemporaryResources tmp) {
-        this(tmp, null, DEFAULT_MEMORY_THRESHOLD);
-    }
-
-    /** Suffix up front: a threshold spill precedes any getPath(suffix) call 
(TIKA-3903). */
-    StreamCache(TemporaryResources tmp, String suffix) {
-        this(tmp, suffix, DEFAULT_MEMORY_THRESHOLD);
+    /**
+     * Suffix up front: a threshold spill precedes any getPath(suffix) call 
(TIKA-3903).
+     * A non-null {@code budget} allows in-memory growth past the per-object 
threshold.
+     */
+    StreamCache(TemporaryResources tmp, String suffix, CacheMemoryBudget 
budget) {
+        this(tmp, suffix, DEFAULT_MEMORY_THRESHOLD, budget);
     }
 
-    StreamCache(TemporaryResources tmp, String suffix, int memoryThreshold) {
+    StreamCache(TemporaryResources tmp, String suffix, int memoryThreshold, 
CacheMemoryBudget budget) {
         this.tmp = tmp;
         this.suffix = suffix;
         this.memoryThreshold = memoryThreshold;
+        this.budget = budget;
         this.memoryBuffer = new byte[Math.min(memoryThreshold, 8192)];
         this.memorySize = 0;
         this.totalSize = 0;
@@ -78,12 +90,12 @@ class StreamCache implements Closeable {
 
         if (memoryBuffer != null) {
             // Still in memory mode
-            if (memorySize >= memoryThreshold) {
-                spillToFile();
-                spillOutputStream.write(b);
-            } else {
+            if (canKeepInMemory(1)) {
                 ensureMemoryCapacity(memorySize + 1);
                 memoryBuffer[memorySize++] = (byte) b;
+            } else {
+                spillToFile();
+                spillOutputStream.write(b);
             }
         } else {
             // Already spilled to file
@@ -101,13 +113,13 @@ class StreamCache implements Closeable {
         }
 
         if (memoryBuffer != null) {
-            if (memorySize + len > memoryThreshold) {
-                spillToFile();
-                spillOutputStream.write(b, off, len);
-            } else {
+            if (canKeepInMemory(len)) {
                 ensureMemoryCapacity(memorySize + len);
                 System.arraycopy(b, off, memoryBuffer, memorySize, len);
                 memorySize += len;
+            } else {
+                spillToFile();
+                spillOutputStream.write(b, off, len);
             }
         } else {
             spillOutputStream.write(b, off, len);
@@ -115,17 +127,62 @@ class StreamCache implements Closeable {
         totalSize += len;
     }
 
+    /**
+     * Decide whether {@code additional} more bytes can stay in memory. 
Content up to the
+     * per-object threshold always may (budget or not, so budget exhaustion is 
never worse
+     * than the no-budget default); beyond it, the shared budget must cover 
the *capacity*
+     * the backing array will actually grow to (arrays grow in doubling steps, 
so logical
+     * bytes would under-count real heap). Invariant:
+     * {@code reserved == max(0, memoryBuffer.length - memoryThreshold)}.
+     */
+    private boolean canKeepInMemory(int additional) {
+        long needed = memorySize + (long) additional;
+        if (needed <= memoryThreshold) {
+            return true;
+        }
+        if (budget == null || needed > MAX_ARRAY_SIZE) {
+            return false;
+        }
+        if (needed <= memoryBuffer.length) {
+            return true;
+        }
+        long targetCapacity =
+                Math.min(MAX_ARRAY_SIZE, Math.max((long) memoryBuffer.length * 
2, needed));
+        long delta = targetCapacity - Math.max(memoryThreshold, 
memoryBuffer.length);
+        if (budget.tryReserve(delta) != delta) {
+            return false;
+        }
+        reserved += delta;
+        return true;
+    }
+
+    /** Releases the reservation once the buffer is no longer held by this 
cache (spilled or
+     *  closed) AND no handed-out in-memory channel still pins the array. */
+    private void maybeReleaseReserved() {
+        if (budget != null && reserved > 0 && channelPins == 0 &&
+                (closed || memoryBuffer == null)) {
+            budget.release(reserved);
+            reserved = 0;
+        }
+    }
+
     private void ensureMemoryCapacity(int needed) {
         if (needed <= memoryBuffer.length) {
             return;
         }
-        int newSize = Math.min(memoryThreshold, Math.max(memoryBuffer.length * 
2, needed));
+        // With a budget, growth is capped at the reserved capacity (see 
canKeepInMemory)
+        long cap = (budget == null) ? memoryThreshold
+                : Math.min((long) memoryThreshold + reserved, MAX_ARRAY_SIZE);
+        int newSize = (int) Math.min(cap, Math.max((long) memoryBuffer.length 
* 2, needed));
         byte[] newBuffer = new byte[newSize];
         System.arraycopy(memoryBuffer, 0, newBuffer, 0, memorySize);
         memoryBuffer = newBuffer;
     }
 
     private void spillToFile() throws IOException {
+        if (closed) {
+            throw new IOException("StreamCache is closed");
+        }
         if (spillFile != null) {
             return; // Already spilled
         }
@@ -144,6 +201,7 @@ class StreamCache implements Closeable {
         // Release memory buffer
         memoryBuffer = null;
         memorySize = 0;
+        maybeReleaseReserved();
     }
 
     /**
@@ -216,6 +274,9 @@ class StreamCache implements Closeable {
      * After this call, the cache is in file-backed mode.
      */
     Path toFile() throws IOException {
+        if (closed) {
+            throw new IOException("StreamCache is closed");
+        }
         if (spillFile == null) {
             spillToFile();
         }
@@ -251,6 +312,24 @@ class StreamCache implements Closeable {
         return spillFile != null;
     }
 
+    /**
+     * If the full content is currently held in memory (not spilled, not 
closed), returns a
+     * read-only random-access channel over it (no copy, no disk). Otherwise 
{@code null}.
+     * The caller is responsible only for content that has actually been 
cached so far.
+     */
+    SeekableByteChannel getInMemorySeekableByteChannel() {
+        if (closed || memoryBuffer == null) {
+            return null;
+        }
+        // The channel pins the array: the budget reservation is held until 
this cache no
+        // longer needs the buffer (spill/close) AND all handed-out channels 
are closed.
+        channelPins++;
+        return new MemorySeekableByteChannel(memoryBuffer, memorySize, () -> {
+            channelPins--;
+            maybeReleaseReserved();
+        });
+    }
+
     @Override
     public void close() throws IOException {
         if (closed) {
@@ -258,6 +337,7 @@ class StreamCache implements Closeable {
         }
         closed = true;
         memoryBuffer = null;
+        maybeReleaseReserved();
 
         if (spillOutputStream != null) {
             spillOutputStream.close();
diff --git a/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java 
b/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java
index 81bc69eaba..53d8942399 100644
--- a/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java
+++ b/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java
@@ -18,6 +18,7 @@ package org.apache.tika.io;
 
 import java.io.Closeable;
 import java.io.IOException;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Path;
 
 /**
@@ -62,7 +63,22 @@ interface TikaInputSource extends Closeable {
      * <p>
      * Must be called when position is 0, otherwise throws IOException.
      *
+     * @param budget shared memory budget governing how much a caching source 
may hold in
+     *               memory before spilling, or {@code null} for the 
per-object default;
+     *               inherently rewindable sources ignore it
      * @throws IOException if position is not 0
      */
-    void enableRewind() throws IOException;
+    void enableRewind(CacheMemoryBudget budget) throws IOException;
+
+    /**
+     * Returns a read-only random-access channel over this source's full 
content: content
+     * already in memory (byte[], unspilled cache) is served from memory; 
file-backed or
+     * spilled content from a file channel; unread stream content is drained 
through the
+     * cache, which decides memory-vs-disk during the drain. Fails for a 
stream-backed
+     * source that has been partially read without rewind enabled. Callers own 
closing the
+     * returned channel. Does not change this source's read position.
+     *
+     * @throws IOException if the source is partially read and cannot be 
rewound
+     */
+    SeekableByteChannel getSeekableByteChannel() throws IOException;
 }
diff --git a/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java 
b/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java
index 320b64f68c..6de71cfa8e 100644
--- a/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java
+++ b/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java
@@ -26,6 +26,7 @@ import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLConnection;
 import java.nio.channels.FileChannel;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -33,6 +34,7 @@ import java.sql.Blob;
 import java.sql.SQLException;
 
 import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.function.IOSupplier;
 import org.apache.commons.io.input.TaggedInputStream;
 
 import org.apache.tika.metadata.HttpHeaders;
@@ -101,6 +103,40 @@ public class TikaInputStream extends TaggedInputStream {
         return new TikaInputStream(inputSource, tmp, ext);
     }
 
+    /**
+     * Creates a TikaInputStream from a re-openable stream supplier. Unlike
+     * {@link #get(InputStream, TemporaryResources, Metadata)} -- which caches 
a one-shot
+     * stream to memory/disk so it can be rewound -- the supplier is 
re-invoked to re-read
+     * the content, so rewinding (e.g. during digesting) never spills to disk. 
A temp file
+     * is created only if {@link #getPath()} is later called (a 
parser/detector needing a
+     * File) or {@link #getSeekableByteChannel()} is asked for content that 
does not fit
+     * in memory.
+     *
+     * @param opener   supplies a fresh InputStream over the same content on 
each call
+     * @param tmp      temporary resources for any on-demand {@link 
#getPath()} spill
+     * @param metadata metadata used for extension/length hints; may be null
+     */
+    public static TikaInputStream get(IOSupplier<InputStream> opener, 
TemporaryResources tmp,
+                                      Metadata metadata) {
+        if (opener == null) {
+            throw new NullPointerException("The opener must not be null");
+        }
+        String ext = getExtension(metadata);
+        long length = -1;
+        if (metadata != null) {
+            String cl = metadata.get(HttpHeaders.CONTENT_LENGTH);
+            if (cl != null) {
+                try {
+                    length = Long.parseLong(cl);
+                } catch (NumberFormatException e) {
+                    length = -1;
+                }
+            }
+        }
+        TikaInputSource inputSource = new ReopenableSource(opener, tmp, 
length, ext);
+        return new TikaInputStream(inputSource, tmp, ext);
+    }
+
     public static TikaInputStream get(InputStream stream) {
         return get(stream, new TemporaryResources(), null);
     }
@@ -478,10 +514,42 @@ public class TikaInputStream extends TaggedInputStream {
      *         (position is not 0); rewind support cannot be enabled 
retroactively
      */
     public void enableRewind() throws IOException {
+        enableRewind(null);
+    }
+
+    /**
+     * Like {@link #enableRewind()}, but supplies a shared {@link 
CacheMemoryBudget} governing
+     * how much may be held in memory before spilling to disk (used only by 
stream-backed
+     * sources); {@code null} falls back to the per-object default.
+     *
+     * @param budget shared memory budget, or {@code null}
+     * @throws IOException if bytes have already been read (position is not 0)
+     */
+    public void enableRewind(CacheMemoryBudget budget) throws IOException {
         TikaInputSource source = inputSource();
         if (source != null) {
-            source.enableRewind();
+            source.enableRewind(budget);
+        }
+    }
+
+    /**
+     * Returns a read-only random-access {@link SeekableByteChannel} over this 
stream's full
+     * content. Unlike {@link #getPath()}/{@link #getFile()}, this never 
forces content that is
+     * already in memory onto disk: in-memory content is served from memory, 
file-backed or
+     * spilled content from a file channel, and unread stream content is 
drained through the
+     * cache which decides memory-vs-disk as it goes. Use this when random 
access is needed
+     * (e.g. reading a zip central directory); reserve {@code getFile()} for 
callers that truly
+     * need a {@link java.io.File}. The caller owns closing the returned 
channel. Does not
+     * disturb this stream's read position.
+     *
+     * @throws IOException if this stream has been partially read without 
rewind enabled
+     */
+    public SeekableByteChannel getSeekableByteChannel() throws IOException {
+        TikaInputSource source = inputSource();
+        if (source == null) {
+            throw new IOException("No TikaInputSource available");
         }
+        return source.getSeekableByteChannel();
     }
 
     @Override
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/multiple/AbstractMultipleParser.java
 
b/tika-core/src/main/java/org/apache/tika/parser/multiple/AbstractMultipleParser.java
index 3e928fd751..ee7c55210b 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/multiple/AbstractMultipleParser.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/multiple/AbstractMultipleParser.java
@@ -33,6 +33,7 @@ import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
 
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.CacheMemoryBudget;
 import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
@@ -244,7 +245,7 @@ public abstract class AbstractMultipleParser implements 
Parser {
                        ContentHandlerFactory handlerFactory, Metadata 
originalMetadata,
                        ParseContext context) throws IOException, SAXException, 
TikaException {
         // Enable rewind capability since we rewind between multiple parsers
-        tis.enableRewind();
+        tis.enableRewind(context == null ? null : 
context.get(CacheMemoryBudget.class));
 
         // Track the metadata between parsers, so we can apply our policy
         Metadata lastMetadata = cloneMetadata(originalMetadata);
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/CacheMemoryBudgetTest.java 
b/tika-core/src/test/java/org/apache/tika/io/CacheMemoryBudgetTest.java
new file mode 100644
index 0000000000..c24c53facb
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/io/CacheMemoryBudgetTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.junit.jupiter.api.Test;
+
+public class CacheMemoryBudgetTest {
+
+    @Test
+    public void testCtorRejectsNonPositive() {
+        assertThrows(IllegalArgumentException.class, () -> new 
CacheMemoryBudget(0));
+        assertThrows(IllegalArgumentException.class, () -> new 
CacheMemoryBudget(-1));
+    }
+
+    @Test
+    public void testAllOrNothing() {
+        CacheMemoryBudget budget = new CacheMemoryBudget(100);
+        assertEquals(60, budget.tryReserve(60));
+        // only 40 left; a 60-byte request must reserve nothing, not 40
+        assertEquals(0, budget.tryReserve(60));
+        assertEquals(60, budget.getReservedBytes());
+        assertEquals(40, budget.tryReserve(40));
+        assertEquals(0, budget.tryReserve(1));
+    }
+
+    @Test
+    public void testZeroAndNegativeRequests() {
+        CacheMemoryBudget budget = new CacheMemoryBudget(100);
+        assertEquals(0, budget.tryReserve(0));
+        assertEquals(0, budget.tryReserve(-5));
+        assertEquals(0, budget.getReservedBytes());
+    }
+
+    @Test
+    public void testReleaseRestoresCapacity() {
+        CacheMemoryBudget budget = new CacheMemoryBudget(100);
+        assertEquals(100, budget.tryReserve(100));
+        assertEquals(0, budget.tryReserve(1));
+        budget.release(30);
+        assertEquals(30, budget.tryReserve(30));
+        budget.release(100);
+        assertEquals(0, budget.getReservedBytes());
+        assertEquals(100, budget.tryReserve(100));
+    }
+
+    @Test
+    public void testConcurrency() throws Exception {
+        final CacheMemoryBudget budget = new CacheMemoryBudget(1000);
+        final AtomicLong netReserved = new AtomicLong();
+        int threads = 8;
+        ExecutorService executor = Executors.newFixedThreadPool(threads);
+        try {
+            List<Callable<Void>> tasks = new ArrayList<>();
+            for (int t = 0; t < threads; t++) {
+                final long seed = 42L + t;
+                tasks.add(() -> {
+                    Random random = new Random(seed);
+                    for (int i = 0; i < 5000; i++) {
+                        long n = 1 + random.nextInt(100);
+                        long got = budget.tryReserve(n);
+                        if (got > 0) {
+                            netReserved.addAndGet(got);
+                            long reserved = budget.getReservedBytes();
+                            assertTrue(reserved <= 1000, "over-reserved: " + 
reserved);
+                            budget.release(got);
+                            netReserved.addAndGet(-got);
+                        }
+                    }
+                    return null;
+                });
+            }
+            for (Future<Void> f : executor.invokeAll(tasks)) {
+                f.get();
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+        assertEquals(0, netReserved.get());
+        assertEquals(0, budget.getReservedBytes());
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/MemorySeekableByteChannelTest.java 
b/tika-core/src/test/java/org/apache/tika/io/MemorySeekableByteChannelTest.java
new file mode 100644
index 0000000000..4c982e278a
--- /dev/null
+++ 
b/tika-core/src/test/java/org/apache/tika/io/MemorySeekableByteChannelTest.java
@@ -0,0 +1,166 @@
+/*
+ * 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.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.FileChannel;
+import java.nio.channels.NonWritableChannelException;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Conformance test: MemorySeekableByteChannel must behave like a read-only 
FileChannel over
+ * the same bytes -- external code (commons-compress ZipFile/SevenZFile) is 
handed either and
+ * is entitled to spec behavior for both. Each test runs against both kinds.
+ */
+public class MemorySeekableByteChannelTest {
+
+    private static final byte[] DATA =
+            
"0123456789abcdefghijklmnopqrstuvwxyz".getBytes(StandardCharsets.UTF_8);
+
+    private static final List<String> KINDS = Arrays.asList("memory", "file");
+
+    @TempDir
+    Path tmp;
+
+    private SeekableByteChannel open(String kind) throws IOException {
+        if ("memory".equals(kind)) {
+            return new MemorySeekableByteChannel(DATA, DATA.length);
+        }
+        Path p = Files.createTempFile(tmp, "msbc", ".bin");
+        Files.write(p, DATA);
+        return FileChannel.open(p, StandardOpenOption.READ);
+    }
+
+    @Test
+    public void testSequentialRead() throws Exception {
+        for (String kind : KINDS) {
+            try (SeekableByteChannel channel = open(kind)) {
+                assertEquals(DATA.length, channel.size(), kind);
+                ByteBuffer buf = ByteBuffer.allocate(10);
+                assertEquals(10, channel.read(buf), kind);
+                assertArrayEquals(Arrays.copyOfRange(DATA, 0, 10), 
buf.array(), kind);
+                assertEquals(10, channel.position(), kind);
+                buf.clear();
+                int total = 10;
+                int n;
+                while ((n = channel.read(buf)) != -1) {
+                    total += n;
+                    buf.clear();
+                }
+                assertEquals(DATA.length, total, kind);
+            }
+        }
+    }
+
+    @Test
+    public void testPositionRoundTripIncludingBeyondSize() throws Exception {
+        for (String kind : KINDS) {
+            try (SeekableByteChannel channel = open(kind)) {
+                channel.position(5);
+                assertEquals(5, channel.position(), kind);
+                ByteBuffer one = ByteBuffer.allocate(1);
+                channel.read(one);
+                assertEquals(DATA[5], one.get(0), kind);
+
+                // Setting a position beyond size is legal; position() must 
echo it and
+                // reads must return EOF
+                long beyond = DATA.length + 100L;
+                channel.position(beyond);
+                assertEquals(beyond, channel.position(), kind);
+                one.clear();
+                assertEquals(-1, channel.read(one), kind);
+                assertEquals(beyond, channel.position(), kind);
+                assertEquals(DATA.length, channel.size(), kind);
+
+                channel.position(DATA.length);
+                one.clear();
+                assertEquals(-1, channel.read(one), kind);
+            }
+        }
+    }
+
+    @Test
+    public void testNegativePositionRejected() throws Exception {
+        for (String kind : KINDS) {
+            try (SeekableByteChannel channel = open(kind)) {
+                assertThrows(IllegalArgumentException.class, () -> 
channel.position(-1), kind);
+            }
+        }
+    }
+
+    @Test
+    public void testZeroRemainingBufferReadsZero() throws Exception {
+        for (String kind : KINDS) {
+            try (SeekableByteChannel channel = open(kind)) {
+                assertEquals(0, channel.read(ByteBuffer.allocate(0)), kind);
+            }
+        }
+    }
+
+    @Test
+    public void testReadOnly() throws Exception {
+        for (String kind : KINDS) {
+            try (SeekableByteChannel channel = open(kind)) {
+                assertThrows(NonWritableChannelException.class,
+                        () -> channel.write(ByteBuffer.allocate(1)), kind);
+                assertThrows(NonWritableChannelException.class, () -> 
channel.truncate(1), kind);
+            }
+        }
+    }
+
+    @Test
+    public void testClosedChannel() throws Exception {
+        for (String kind : KINDS) {
+            SeekableByteChannel channel = open(kind);
+            channel.close();
+            assertFalse(channel.isOpen(), kind);
+            assertThrows(ClosedChannelException.class,
+                    () -> channel.read(ByteBuffer.allocate(1)), kind);
+            assertThrows(ClosedChannelException.class, channel::position, 
kind);
+            assertThrows(ClosedChannelException.class, () -> 
channel.position(0), kind);
+            assertThrows(ClosedChannelException.class, channel::size, kind);
+            channel.close(); // idempotent
+        }
+    }
+
+    @Test
+    public void testPartialLengthView() throws Exception {
+        // MemorySeekableByteChannel may expose a prefix of a larger array
+        try (SeekableByteChannel channel = new MemorySeekableByteChannel(DATA, 
10)) {
+            assertEquals(10, channel.size());
+            ByteBuffer buf = ByteBuffer.allocate(100);
+            assertEquals(10, channel.read(buf));
+            assertEquals(-1, channel.read(buf));
+        }
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java 
b/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
new file mode 100644
index 0000000000..d3142dd461
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.commons.io.function.IOSupplier;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+public class ReopenableSourceTest {
+
+    private static final int FLOOR = 1024 * 1024; // 
ReopenableSource.IN_MEMORY_FLOOR
+
+    private final TemporaryResources tmp = new TemporaryResources();
+
+    @AfterEach
+    public void tearDown() throws IOException {
+        tmp.close();
+    }
+
+    private static byte[] data(int size) {
+        byte[] data = new byte[size];
+        for (int i = 0; i < size; i++) {
+            data[i] = (byte) (i * 31 + 7);
+        }
+        return data;
+    }
+
+    private static IOSupplier<InputStream> countingOpener(byte[] data, 
AtomicInteger opens) {
+        return () -> {
+            opens.incrementAndGet();
+            return new ByteArrayInputStream(data);
+        };
+    }
+
+    private static byte[] readFully(SeekableByteChannel channel) throws 
IOException {
+        ByteBuffer buf = ByteBuffer.allocate((int) channel.size());
+        while (buf.hasRemaining() && channel.read(buf) != -1) {
+            // keep reading
+        }
+        return buf.array();
+    }
+
+    @Test
+    public void testReadAndRewindReopens() throws Exception {
+        byte[] data = data(1000);
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, null)) {
+            source.enableRewind(null);
+            byte[] first = source.readAllBytes();
+            assertArrayEquals(data, first);
+            assertEquals(1, opens.get());
+            source.seekTo(0);
+            byte[] second = source.readAllBytes();
+            assertArrayEquals(data, second);
+            assertEquals(2, opens.get());
+        }
+    }
+
+    @Test
+    public void testMarkReset() throws Exception {
+        byte[] data = data(1000);
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, null)) {
+            byte[] buf = new byte[100];
+            source.readNBytes(buf, 0, buf.length);
+            source.mark(0);
+            source.readNBytes(buf, 0, buf.length);
+            source.reset();
+            int b = source.read();
+            assertEquals(data[100] & 0xFF, b);
+        }
+    }
+
+    @Test
+    public void testResetWithoutMarkThrows() throws Exception {
+        try (ReopenableSource source = new ReopenableSource(
+                countingOpener(data(10), new AtomicInteger()), tmp, 10, null)) 
{
+            assertThrows(IOException.class, source::reset);
+        }
+    }
+
+    @Test
+    public void testEnableRewindAfterReadThrows() throws Exception {
+        try (ReopenableSource source = new ReopenableSource(
+                countingOpener(data(10), new AtomicInteger()), tmp, 10, null)) 
{
+            source.enableRewind(null);  // at 0: fine
+            source.read();
+            assertThrows(IOException.class, () -> source.enableRewind(null));
+        }
+    }
+
+    @Test
+    public void testGetPathSpillsOnceWithSuffix() throws Exception {
+        byte[] data = data(1000);
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, ".zip")) {
+            assertFalse(source.hasPath());
+            Path p = source.getPath(null);
+            assertTrue(source.hasPath());
+            assertTrue(p.getFileName().toString().endsWith(".zip"));
+            assertEquals(1, opens.get());
+            assertEquals(p, source.getPath(null));
+            assertEquals(1, opens.get());
+            // post-spill reads come from the file, not the opener
+            source.seekTo(0);
+            assertArrayEquals(data, source.readAllBytes());
+            assertEquals(1, opens.get());
+        }
+    }
+
+    @Test
+    public void testChannelSmallContentInMemoryAndRetained() throws Exception {
+        byte[] data = data(50_000);
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, null)) {
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertInstanceOf(MemorySeekableByteChannel.class, channel);
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertEquals(1, opens.get());
+            assertFalse(source.hasPath());
+            // second channel served from the retained buffer -- no re-read
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertEquals(1, opens.get());
+        }
+    }
+
+    @Test
+    public void testChannelDoesNotDisturbReadPosition() throws Exception {
+        byte[] data = data(1000);
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, null)) {
+            byte[] buf = new byte[10];
+            source.readNBytes(buf, 0, buf.length);
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                readFully(channel);
+            }
+            int b = source.read();
+            assertEquals(data[10] & 0xFF, b);
+        }
+    }
+
+    @Test
+    public void testChannelOverFloorWithoutBudgetSpills() throws Exception {
+        byte[] data = data(FLOOR + 1);
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, null)) {
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertInstanceOf(FileChannel.class, channel);
+                assertEquals(data.length, channel.size());
+            }
+            assertTrue(source.hasPath());
+            // declared length routed straight to spill: exactly one full read
+            assertEquals(1, opens.get());
+        }
+    }
+
+    @Test
+    public void testChannelOverFloorWithBudgetStaysInMemory() throws Exception 
{
+        byte[] data = data(FLOOR + 100);
+        AtomicInteger opens = new AtomicInteger();
+        CacheMemoryBudget budget = new CacheMemoryBudget(16L * 1024 * 1024);
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, null)) {
+            source.enableRewind(budget);
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertInstanceOf(MemorySeekableByteChannel.class, channel);
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertFalse(source.hasPath());
+            assertTrue(budget.getReservedBytes() > 0, "capacity beyond the 
floor is reserved");
+        }
+        assertEquals(0, budget.getReservedBytes(), "close() releases the 
retained reservation");
+    }
+
+    @Test
+    public void testChannelOutlivingSourceHoldsReservation() throws Exception {
+        byte[] data = data(FLOOR + 100);
+        CacheMemoryBudget budget = new CacheMemoryBudget(16L * 1024 * 1024);
+        ReopenableSource source = new ReopenableSource(
+                countingOpener(data, new AtomicInteger()), tmp, data.length, 
null);
+        source.enableRewind(budget);
+        SeekableByteChannel channel = source.getSeekableByteChannel();
+        source.close();
+        assertTrue(budget.getReservedBytes() > 0,
+                "reservation must be held while a channel still pins the 
retained buffer");
+        assertArrayEquals(data, readFully(channel));
+        channel.close();
+        assertEquals(0, budget.getReservedBytes(), "last channel close 
releases");
+        channel.close(); // idempotent: no double-release
+        assertEquals(0, budget.getReservedBytes());
+    }
+
+    @Test
+    public void testChannelBudgetExhaustedSpills() throws Exception {
+        byte[] data = data(FLOOR + 2048);
+        AtomicInteger opens = new AtomicInteger();
+        CacheMemoryBudget budget = new CacheMemoryBudget(1024);  // too small 
for beyond-floor
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
data.length, null)) {
+            source.enableRewind(budget);
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertInstanceOf(FileChannel.class, channel);
+                assertEquals(data.length, channel.size());
+            }
+            assertEquals(0, budget.getReservedBytes(), "failed reservation 
fully released");
+        }
+    }
+
+    @Test
+    public void testLyingDeclaredLengthCorrected() throws Exception {
+        byte[] data = data(500);
+        AtomicInteger opens = new AtomicInteger();
+        // declared length lies high
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
400_000, null)) {
+            assertEquals(400_000, source.getLength());
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertEquals(500, channel.size());
+            }
+            assertEquals(500, source.getLength(), "full read overrides the 
lying declared length");
+        }
+        // declared length lies high, spill path
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
400_000, null)) {
+            source.getPath(null);
+            assertEquals(500, source.getLength());
+        }
+        // declared length lies low: growth still captures everything
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
100, null)) {
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertEquals(500, source.getLength());
+        }
+    }
+
+    @Test
+    public void testUnknownLengthChannel() throws Exception {
+        byte[] data = data(100_000);
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(data, opens), tmp, 
-1, null)) {
+            assertEquals(-1, source.getLength());
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertInstanceOf(MemorySeekableByteChannel.class, channel);
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertEquals(data.length, source.getLength());
+        }
+    }
+
+    @Test
+    public void testEmptyContent() throws Exception {
+        AtomicInteger opens = new AtomicInteger();
+        try (ReopenableSource source =
+                     new ReopenableSource(countingOpener(new byte[0], opens), 
tmp, 0, null)) {
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertEquals(0, channel.size());
+                assertEquals(-1, channel.read(ByteBuffer.allocate(1)));
+            }
+        }
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/StreamCacheBudgetTest.java 
b/tika-core/src/test/java/org/apache/tika/io/StreamCacheBudgetTest.java
new file mode 100644
index 0000000000..0b2b7a941b
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/io/StreamCacheBudgetTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+public class StreamCacheBudgetTest {
+
+    private static final int THRESHOLD = 1024;
+
+    private final TemporaryResources tmp = new TemporaryResources();
+
+    @AfterEach
+    public void tearDown() throws IOException {
+        tmp.close();
+    }
+
+    private static void append(StreamCache cache, int n) throws IOException {
+        byte[] data = new byte[n];
+        cache.append(data, 0, n);
+    }
+
+    @Test
+    public void testNoBudgetSpillsAtThreshold() throws Exception {
+        try (StreamCache cache = new StreamCache(tmp, null, THRESHOLD, null)) {
+            append(cache, THRESHOLD);
+            assertFalse(cache.isFileBacked());
+            append(cache, 1);
+            assertTrue(cache.isFileBacked());
+        }
+    }
+
+    @Test
+    public void testBudgetAllowsGrowthPastThreshold() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(1024 * 1024);
+        try (StreamCache cache = new StreamCache(tmp, null, THRESHOLD, 
budget)) {
+            append(cache, 10 * THRESHOLD);
+            assertFalse(cache.isFileBacked());
+            // channel must be closed: an open channel pins the reservation 
past cache close
+            try (java.nio.channels.SeekableByteChannel channel =
+                    cache.getInMemorySeekableByteChannel()) {
+                assertNotNull(channel);
+            }
+            assertTrue(budget.getReservedBytes() >= 9 * THRESHOLD,
+                    "capacity beyond the threshold is reserved");
+        }
+        assertEquals(0, budget.getReservedBytes(), "close() releases the 
reservation");
+    }
+
+    @Test
+    public void testUpToThresholdNeverTouchesBudget() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(1);
+        try (StreamCache cache = new StreamCache(tmp, null, THRESHOLD, 
budget)) {
+            // an exhausted/tiny budget must not force sub-threshold content 
to disk
+            append(cache, THRESHOLD);
+            assertFalse(cache.isFileBacked());
+            assertEquals(0, budget.getReservedBytes());
+        }
+    }
+
+    @Test
+    public void testBudgetExhaustedSpillsAndReleases() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(2048);
+        try (StreamCache cache = new StreamCache(tmp, null, THRESHOLD, 
budget)) {
+            append(cache, 100 * THRESHOLD);
+            assertTrue(cache.isFileBacked());
+            assertEquals(0, budget.getReservedBytes(), "spill returns the 
reservation");
+            assertNull(cache.getInMemorySeekableByteChannel());
+            assertEquals(100 * THRESHOLD, cache.size());
+        }
+    }
+
+    @Test
+    public void testTwoCachesShareBudget() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(8 * THRESHOLD);
+        try (StreamCache first = new StreamCache(tmp, null, THRESHOLD, budget);
+                StreamCache second = new StreamCache(tmp, null, THRESHOLD, 
budget)) {
+            append(first, 8 * THRESHOLD);
+            assertFalse(first.isFileBacked());
+            // pool is (mostly) consumed; the second cache must spill past its 
threshold
+            append(second, 100 * THRESHOLD);
+            assertTrue(second.isFileBacked());
+            first.close();
+            // after the first cache releases, a third can grow again
+            try (StreamCache third = new StreamCache(tmp, null, THRESHOLD, 
budget)) {
+                append(third, 4 * THRESHOLD);
+                assertFalse(third.isFileBacked());
+            }
+        }
+    }
+
+    @Test
+    public void testReadBackIntact() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(1024 * 1024);
+        try (StreamCache cache = new StreamCache(tmp, null, THRESHOLD, 
budget)) {
+            byte[] data = new byte[5000];
+            for (int i = 0; i < data.length; i++) {
+                data[i] = (byte) i;
+            }
+            cache.append(data, 0, data.length);
+            byte[] out = new byte[data.length];
+            assertEquals(data.length, cache.readAt(0, out, 0, out.length));
+            for (int i = 0; i < data.length; i++) {
+                assertEquals(data[i], out[i]);
+            }
+        }
+    }
+
+    @Test
+    public void testChannelOutlivingCacheHoldsReservation() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(1024 * 1024);
+        StreamCache cache = new StreamCache(tmp, null, THRESHOLD, budget);
+        append(cache, 10 * THRESHOLD);
+        java.nio.channels.SeekableByteChannel channel = 
cache.getInMemorySeekableByteChannel();
+        assertNotNull(channel);
+        cache.close();
+        assertTrue(budget.getReservedBytes() > 0,
+                "reservation must be held while a channel still pins the 
buffer");
+        channel.close();
+        assertEquals(0, budget.getReservedBytes(), "last channel close 
releases");
+        channel.close(); // idempotent: no double-release
+        assertEquals(0, budget.getReservedBytes());
+    }
+
+    @Test
+    public void testUseAfterCloseThrows() throws Exception {
+        StreamCache cache = new StreamCache(tmp, null, THRESHOLD, null);
+        append(cache, 10);
+        cache.close();
+        assertThrows(IOException.class, () -> cache.append(new byte[1], 0, 1));
+        assertThrows(IOException.class, cache::toFile);
+        assertNull(cache.getInMemorySeekableByteChannel());
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamChannelTest.java 
b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamChannelTest.java
new file mode 100644
index 0000000000..c0135f7618
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamChannelTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * TikaInputStream.getSeekableByteChannel() across the source types.
+ */
+public class TikaInputStreamChannelTest {
+
+    @TempDir
+    Path tmp;
+
+    private static byte[] data(int size) {
+        byte[] data = new byte[size];
+        for (int i = 0; i < size; i++) {
+            data[i] = (byte) (i * 17 + 3);
+        }
+        return data;
+    }
+
+    private static byte[] readFully(SeekableByteChannel channel) throws 
IOException {
+        ByteBuffer buf = ByteBuffer.allocate((int) channel.size());
+        while (buf.hasRemaining() && channel.read(buf) != -1) {
+            // keep reading
+        }
+        return buf.array();
+    }
+
+    @Test
+    public void testByteArrayStaysInMemory() throws Exception {
+        byte[] data = data(10_000);
+        try (TikaInputStream tis = TikaInputStream.get(data)) {
+            try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertFalse(tis.hasFile(), "in-memory content must not be forced 
to disk");
+        }
+    }
+
+    @Test
+    public void testFileBacked() throws Exception {
+        byte[] data = data(10_000);
+        Path p = Files.createTempFile(tmp, "chan", ".bin");
+        Files.write(p, data);
+        try (TikaInputStream tis = TikaInputStream.get(p)) {
+            try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+                assertInstanceOf(FileChannel.class, channel);
+                assertArrayEquals(data, readFully(channel));
+            }
+        }
+    }
+
+    @Test
+    public void testUnreadStreamDrainsAndSetsContentLength() throws Exception {
+        byte[] data = data(10_000);
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis =
+                     TikaInputStream.get(new ByteArrayInputStream(data), 
metadata)) {
+            try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertEquals(Long.toString(data.length), 
metadata.get(HttpHeaders.CONTENT_LENGTH));
+            assertEquals(data.length, tis.getLength());
+            // read position was not disturbed by the drain
+            assertEquals(data[0] & 0xFF, tis.read());
+        }
+    }
+
+    @Test
+    public void testPartiallyReadStreamThrows() throws Exception {
+        byte[] data = data(100);
+        try (TikaInputStream tis = TikaInputStream.get(new 
ByteArrayInputStream(data))) {
+            tis.read();
+            assertThrows(IOException.class, tis::getSeekableByteChannel);
+        }
+    }
+
+    @Test
+    public void testPartiallyReadWithRewindEnabledWorks() throws Exception {
+        byte[] data = data(100);
+        try (TikaInputStream tis = TikaInputStream.get(new 
ByteArrayInputStream(data))) {
+            tis.enableRewind();
+            tis.read();
+            try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertEquals(data[1] & 0xFF, tis.read());
+        }
+    }
+
+    @Test
+    public void testAfterGetPathServesFileChannel() throws Exception {
+        byte[] data = data(100);
+        try (TikaInputStream tis = TikaInputStream.get(new 
ByteArrayInputStream(data))) {
+            tis.getPath();
+            try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+                assertInstanceOf(FileChannel.class, channel);
+                assertArrayEquals(data, readFully(channel));
+            }
+        }
+    }
+
+    @Test
+    public void testStreamWithBudgetPastPerObjectThreshold() throws Exception {
+        // over the 1MB per-object threshold: without a budget this drains to 
a spill file,
+        // with a budget it stays in memory
+        byte[] data = data(2 * 1024 * 1024);
+        try (TikaInputStream tis = TikaInputStream.get(new 
ByteArrayInputStream(data))) {
+            try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+                assertInstanceOf(FileChannel.class, channel);
+            }
+        }
+        CacheMemoryBudget budget = new CacheMemoryBudget(16L * 1024 * 1024);
+        try (TikaInputStream tis = TikaInputStream.get(new 
ByteArrayInputStream(data))) {
+            tis.enableRewind(budget);
+            try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+                assertFalse(channel instanceof FileChannel);
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertTrue(budget.getReservedBytes() > 0);
+        }
+        assertEquals(0, budget.getReservedBytes(), "closing the stream 
releases the reservation");
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamTest.java 
b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamTest.java
index 1ac4f4a37a..b5498f9945 100644
--- a/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamTest.java
+++ b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamTest.java
@@ -685,7 +685,7 @@ public class TikaInputStreamTest {
         try (TemporaryResources tmp = new TemporaryResources()) {
             CachingSource source = new CachingSource(
                     new ByteArrayInputStream(data), tmp, -1, metadata, null);
-            source.enableRewind(); // Enable caching for spill support
+            source.enableRewind(null); // Enable caching for spill support
 
             // Read all data
             byte[] buffer = new byte[data.length];
@@ -721,7 +721,7 @@ public class TikaInputStreamTest {
         try (TemporaryResources tmp = new TemporaryResources()) {
             CachingSource source = new CachingSource(
                     new ByteArrayInputStream(data), tmp, -1, metadata, null);
-            source.enableRewind(); // Enable caching for seek/spill support
+            source.enableRewind(null); // Enable caching for seek/spill support
 
             // Read and spill
             IOUtils.toByteArray(source);
@@ -742,7 +742,7 @@ public class TikaInputStreamTest {
         try (TemporaryResources tmp = new TemporaryResources()) {
             CachingSource source = new CachingSource(
                     new ByteArrayInputStream(data), tmp, -1, null, null);
-            source.enableRewind(); // Enable caching for seek support
+            source.enableRewind(null); // Enable caching for seek support
 
             // Read first 5 bytes
             byte[] buf = new byte[5];
@@ -768,7 +768,7 @@ public class TikaInputStreamTest {
         try (TemporaryResources tmp = new TemporaryResources()) {
             CachingSource source = new CachingSource(
                     new ByteArrayInputStream(data), tmp, -1, null, null);
-            source.enableRewind(); // Enable caching for spill/seek support
+            source.enableRewind(null); // Enable caching for spill/seek support
 
             // Read first 5 bytes
             byte[] buf = new byte[5];
@@ -1052,7 +1052,7 @@ public class TikaInputStreamTest {
         TemporaryResources tmp = new TemporaryResources();
         CachingSource source = new CachingSource(
                 new ByteArrayInputStream(new byte[1024 * 1024 + 1]), tmp, -1, 
null, null);
-        source.enableRewind();
+        source.enableRewind(null);
         IOUtils.toByteArray(source);
         source.seekTo(0);
         assertEquals(0, source.read(), "precondition: reading from the spilled 
cache");
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-crypto-module/src/main/java/org/apache/tika/parser/crypto/Pkcs7Parser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-crypto-module/src/main/java/org/apache/tika/parser/crypto/Pkcs7Parser.java
index 78f2e68180..a901bc347a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-crypto-module/src/main/java/org/apache/tika/parser/crypto/Pkcs7Parser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-crypto-module/src/main/java/org/apache/tika/parser/crypto/Pkcs7Parser.java
@@ -18,7 +18,7 @@ package org.apache.tika.parser.crypto;
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.nio.file.Files;
+import java.nio.channels.Channels;
 import java.util.Set;
 
 import org.bouncycastle.asn1.ASN1ObjectIdentifier;
@@ -101,7 +101,7 @@ public class Pkcs7Parser implements Parser {
     private void parseSignedData(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
                                  ParseContext context)
             throws IOException, SAXException, TikaException {
-        try (InputStream is = Files.newInputStream(tis.getPath())) {
+        try (InputStream is = 
Channels.newInputStream(tis.getSeekableByteChannel())) {
             DigestCalculatorProvider digestCalculatorProvider =
                     new 
JcaDigestCalculatorProviderBuilder().setProvider("BC").build();
             CMSSignedDataParser parser = new 
CMSSignedDataParser(digestCalculatorProvider, is);
@@ -131,7 +131,7 @@ public class Pkcs7Parser implements Parser {
     private void extractCompressedContent(TikaInputStream tis, ContentHandler 
handler,
                                           Metadata metadata, ParseContext 
context)
             throws IOException, SAXException, TikaException {
-        try (InputStream is = Files.newInputStream(tis.getPath())) {
+        try (InputStream is = 
Channels.newInputStream(tis.getSeekableByteChannel())) {
             CMSCompressedDataParser parser = new CMSCompressedDataParser(is);
             CMSTypedStream content = parser.getContent(new 
ZlibExpanderProvider());
             BoundedInputStream inflated;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
index d3c4ec2515..b7e498cebf 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
@@ -17,7 +17,8 @@
 package org.apache.tika.parser.microsoft.onenote;
 
 import java.io.IOException;
-import java.nio.file.Files;
+import java.io.InputStream;
+import java.nio.channels.Channels;
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collections;
@@ -163,7 +164,10 @@ public class OneNoteParser implements Parser {
             } else if (header.isLegacyOrAlternativePackaging()) {
                 try {
                     AlternativePackaging alternatePackageOneStoreFile = new 
AlternativePackaging();
-                    byte[] bytes = Files.readAllBytes(tis.getPath());
+                    byte[] bytes;
+                    try (InputStream is = 
Channels.newInputStream(tis.getSeekableByteChannel())) {
+                        bytes = is.readAllBytes();
+                    }
                     //enable streaming deserialization
                     
alternatePackageOneStoreFile.doDeserializeFromByteArray(bytes, 0);
 
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 078da8e478..bc525b6aff 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
@@ -64,6 +64,7 @@ import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLBalancingHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 import org.apache.tika.utils.XMLReaderUtils;
+import org.apache.tika.zip.utils.ZipFileHelper;
 
 /**
  * Epub parser
@@ -152,7 +153,7 @@ public class EpubParser implements Parser {
             return bufferedParseZipFile((ZipFile) tis.getOpenContainer(), 
bodyHandler,
                     normalizer, xhtml, metadata, context);
         }
-        try (ZipFile zipFile = 
ZipFile.builder().setFile(tis.getPath().toFile()).get()) {
+        try (ZipFile zipFile = ZipFileHelper.open(tis, null)) {
             return bufferedParseZipFile(zipFile, bodyHandler, normalizer, 
xhtml, 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/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 a624ecf9d1..c500689ab4 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
@@ -58,6 +58,7 @@ import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.EndDocumentShieldingContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 import org.apache.tika.utils.XMLReaderUtils;
+import org.apache.tika.zip.utils.ZipFileHelper;
 
 /**
  * OpenOffice parser
@@ -167,14 +168,13 @@ public class OpenDocumentParser implements Parser {
 
         EmbeddedDocumentExtractor embeddedDocumentExtractor = 
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
 
-        // Open the Zip stream
-        // Use a File if we can, and an already open zip is even better
+        // Open the Zip stream; an already open zip from the detector is best.
         ZipFile zipFile = null;
         Object container = tis.getOpenContainer();
         if (container instanceof ZipFile) {
             zipFile = (ZipFile) container;
         } else {
-            zipFile = ZipFile.builder().setFile(tis.getFile()).get();
+            zipFile = ZipFileHelper.open(tis, null);
             tis.setOpenContainer(zipFile);
         }
         // Prepare to handle the content
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/CompressorParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/CompressorParser.java
index b37abf9309..68d3198479 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/CompressorParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/CompressorParser.java
@@ -35,7 +35,7 @@ import static 
org.apache.tika.metadata.HttpHeaders.CONTENT_TYPE;
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.nio.file.Files;
+import java.nio.channels.Channels;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -251,13 +251,14 @@ public class CompressorParser implements Parser {
                 // TIKA-4221 / COMPRESS-721 workaround: commons-compress' 
Pack200CompressorInputStream
                 // reflects into java.io internals (FilterInputStream.in / 
FileInputStream.path) to
                 // bound its input, which throws InaccessibleObjectException 
on Java 17+. A
-                // TikaInputStream is a FilterInputStream, so it triggers 
this. Spool to a file and
-                // reopen via Files.newInputStream (a ChannelInputStream) -- 
the one input type
-                // commons-compress does not reflect into. 
Pack200CompressorInputStream reads its
-                // input fully in the constructor (IN_MEMORY) and then serves 
bytes from an in-memory
-                // buffer, so the channel stream can be closed immediately 
afterward. Remove this once
-                // Tika depends on a commons-compress release that contains 
the COMPRESS-721 fix.
-                try (InputStream packStream = 
Files.newInputStream(tis.getPath())) {
+                // TikaInputStream is a FilterInputStream, so it triggers 
this. Re-read via
+                // Channels.newInputStream (a ChannelInputStream) -- the one 
input type
+                // commons-compress does not reflect into -- without forcing 
in-memory content to
+                // disk. Pack200CompressorInputStream reads its input fully in 
the constructor
+                // (IN_MEMORY) and then serves bytes from an in-memory buffer, 
so the channel
+                // stream can be closed immediately afterward. Remove this 
once Tika depends on a
+                // commons-compress release that contains the COMPRESS-721 fix.
+                try (InputStream packStream = 
Channels.newInputStream(tis.getSeekableByteChannel())) {
                     cis = 
factory.createCompressorInputStream(CompressorStreamFactory.PACK200,
                             packStream);
                 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
index 14cab9f9cd..1bf93fb580 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
@@ -20,6 +20,7 @@ import static 
org.apache.tika.detect.zip.PackageConstants.SEVENZ;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.channels.SeekableByteChannel;
 import java.util.Collections;
 import java.util.Set;
 
@@ -113,10 +114,12 @@ public class SevenZParser extends AbstractArchiveParser {
         }
 
         SevenZFile sevenZFile;
+        // SevenZFile.close() closes the channel it was built on
+        SeekableByteChannel channel = tis.getSeekableByteChannel();
         try {
             // Use setMaxMemoryLimitKiB (direct KiB); setMaxMemoryLimitKb 
divides the arg by 1024.
             SevenZFile.Builder builder = new SevenZFile.Builder()
-                    .setFile(tis.getFile())
+                    .setSeekableByteChannel(channel)
                     .setMaxMemoryLimitKiB(defaultConfig.getMemoryLimitInKb());
             if (password == null) {
                 sevenZFile = builder.get();
@@ -124,11 +127,17 @@ public class SevenZParser extends AbstractArchiveParser {
                 sevenZFile = builder.setPassword(password.toCharArray()).get();
             }
         } catch (PasswordRequiredException e) {
+            channel.close();
             throw new EncryptedDocumentException(e);
         } catch (MemoryLimitException e) {
             // The limit can be exceeded at open time (assertValidity) as well 
as lazily on the
             // first getNextEntry() when the LZMA/LZMA2 dictionary is 
allocated.
+            channel.close();
             throw new TikaMemoryLimitException(e.getMessage());
+        } catch (IOException | RuntimeException e) {
+            // commons-compress throws unchecked exceptions on corrupt headers
+            channel.close();
+            throw e;
         }
 
         metadata.set(HttpHeaders.CONTENT_TYPE, SEVENZ.toString());
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
index de5b8453c2..c99264fdc0 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
@@ -20,7 +20,6 @@ import static org.apache.tika.detect.zip.PackageConstants.JAR;
 import static org.apache.tika.detect.zip.PackageConstants.ZIP;
 
 import java.io.IOException;
-import java.io.InputStream;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.attribute.FileTime;
@@ -62,6 +61,7 @@ import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.sax.XHTMLContentHandler;
 import org.apache.tika.utils.ParserUtils;
+import org.apache.tika.zip.utils.ZipFileHelper;
 
 /**
  * Parser for ZIP and JAR archives using file-based access for complete 
metadata extraction.
@@ -229,11 +229,7 @@ public class ZipParser extends AbstractArchiveParser {
         // This handles cases where parser is called without detector
         ZipFile zipFile = null;
         try {
-            ZipFile.Builder builder = ZipFile.builder().setFile(tis.getFile());
-            if (config.getEntryEncoding() != null) {
-                builder.setCharset(config.getEntryEncoding());
-            }
-            zipFile = builder.get();
+            zipFile = ZipFileHelper.open(tis, config.getEntryEncoding());
             tis.setOpenContainer(zipFile);
         } catch (IOException e) {
             // ZipFile failed - fall back to streaming
@@ -498,8 +494,10 @@ public class ZipParser extends AbstractArchiveParser {
 
         if (extractor.shouldParseEmbedded(entryMetadata, context)) {
             TemporaryResources tmp = new TemporaryResources();
-            try (InputStream entryStream = zipFile.getInputStream(entry)) {
-                TikaInputStream tis = TikaInputStream.get(entryStream, tmp, 
entryMetadata);
+            // Re-openable source: rewind (e.g. for digesting) re-opens the 
entry instead of
+            // buffering/spilling it
+            try (TikaInputStream tis = TikaInputStream.get(
+                    () -> zipFile.getInputStream(entry), tmp, entryMetadata)) {
                 extractor.parseEmbedded(tis, xhtml, entryMetadata, context, 
true);
             } catch (UnsupportedZipFeatureException e) {
                 EmbeddedDocumentUtil.recordEmbeddedStreamException(e, 
parentMetadata);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/ZipEmbeddedDigestTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/ZipEmbeddedDigestTest.java
new file mode 100644
index 0000000000..0010254efe
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/ZipEmbeddedDigestTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.pkg;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.apache.commons.codec.binary.Hex;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.digest.DigesterFactory;
+import org.apache.tika.digest.InputStreamDigester;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Digest-then-parse round trip over zip entries: with a DigesterFactory in 
the context,
+ * every embedded entry must get a correct digest AND its content must still 
be extracted
+ * afterward (the re-openable entry source rewinds by re-opening the entry).
+ */
+public class ZipEmbeddedDigestTest extends AbstractPkgTest {
+
+    private static final String DIGEST_KEY = "tk:digest:MD5";
+
+    @TempDir
+    Path tmp;
+
+    private static byte[] entryBytes(String name, int size) {
+        byte[] data = new byte[size];
+        byte[] seed = name.getBytes(StandardCharsets.UTF_8);
+        for (int i = 0; i < size; i++) {
+            data[i] = (byte) (seed[i % seed.length] + i);
+        }
+        return data;
+    }
+
+    /** name -> content; includes a nested zip to exercise embedded random 
access. */
+    private Map<String, byte[]> buildEntries() throws Exception {
+        Map<String, byte[]> entries = new HashMap<>();
+        entries.put("small.txt", "hello embedded 
world".getBytes(StandardCharsets.UTF_8));
+        entries.put("bigger.dat", entryBytes("bigger.dat", 300_000));
+        ByteArrayOutputStream inner = new ByteArrayOutputStream();
+        try (ZipOutputStream zos = new ZipOutputStream(inner)) {
+            zos.putNextEntry(new ZipEntry("inner.txt"));
+            zos.write("nested entry".getBytes(StandardCharsets.UTF_8));
+            zos.closeEntry();
+        }
+        entries.put("nested.zip", inner.toByteArray());
+        return entries;
+    }
+
+    private Path buildZip(Map<String, byte[]> entries) throws Exception {
+        Path zip = Files.createTempFile(tmp, "digest-test", ".zip");
+        try (ZipOutputStream zos = new 
ZipOutputStream(Files.newOutputStream(zip))) {
+            for (Map.Entry<String, byte[]> e : entries.entrySet()) {
+                zos.putNextEntry(new ZipEntry(e.getKey()));
+                zos.write(e.getValue());
+                zos.closeEntry();
+            }
+        }
+        return zip;
+    }
+
+    private static String md5(byte[] data) throws Exception {
+        return 
Hex.encodeHexString(MessageDigest.getInstance("MD5").digest(data));
+    }
+
+    private ParseContext digestContext(CacheMemoryBudget budget) {
+        ParseContext context = new ParseContext();
+        context.set(DigesterFactory.class, () -> new InputStreamDigester(
+                "MD5", DIGEST_KEY, Hex::encodeHexString));
+        if (budget != null) {
+            context.set(CacheMemoryBudget.class, budget);
+        }
+        return context;
+    }
+
+    private void assertDigestsAndContent(List<Metadata> metadataList,
+                                         Map<String, byte[]> entries) throws 
Exception {
+        Map<String, Metadata> byName = new HashMap<>();
+        for (Metadata m : metadataList) {
+            String name = m.get(TikaCoreProperties.RESOURCE_NAME_KEY);
+            if (name != null) {
+                byName.put(name, m);
+            }
+        }
+        for (Map.Entry<String, byte[]> e : entries.entrySet()) {
+            Metadata m = byName.get(e.getKey());
+            assertTrue(m != null, "missing embedded metadata for " + 
e.getKey());
+            assertEquals(md5(e.getValue()), m.get(DIGEST_KEY),
+                    "wrong digest for " + e.getKey());
+        }
+        // Parse-after-digest still works: the container's content lists its 
entries...
+        String containerContent = 
metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
+        assertTrue(containerContent != null && 
containerContent.contains("small.txt"),
+                "container content missing after digest rewind");
+        // ...and the nested zip was digested AND recursed into (its entry 
parsed at depth 2),
+        // proving the rewind-after-digest of a zip entry yields parseable 
content
+        Metadata inner = byName.get("inner.txt");
+        assertTrue(inner != null, "nested zip entry not recursed after digest 
rewind");
+        assertEquals(md5("nested entry".getBytes(StandardCharsets.UTF_8)),
+                inner.get(DIGEST_KEY), "wrong digest for inner.txt");
+    }
+
+    @Test
+    public void testEmbeddedDigestsWithoutBudget() throws Exception {
+        Map<String, byte[]> entries = buildEntries();
+        Path zip = buildZip(entries);
+        List<Metadata> metadataList = getRecursiveMetadata(zip, 
digestContext(null), false);
+        assertDigestsAndContent(metadataList, entries);
+    }
+
+    @Test
+    public void testEmbeddedDigestsWithBudget() throws Exception {
+        Map<String, byte[]> entries = buildEntries();
+        Path zip = buildZip(entries);
+        CacheMemoryBudget budget = new CacheMemoryBudget(64L * 1024 * 1024);
+        List<Metadata> metadataList = getRecursiveMetadata(zip, 
digestContext(budget), false);
+        assertDigestsAndContent(metadataList, entries);
+        assertEquals(0, budget.getReservedBytes(),
+                "budget reservations must all be released after the parse");
+    }
+
+    @Test
+    public void testEmbeddedDigestsWithTinyBudget() throws Exception {
+        // an effectively exhausted budget must not break digests or extraction
+        Map<String, byte[]> entries = buildEntries();
+        Path zip = buildZip(entries);
+        CacheMemoryBudget budget = new CacheMemoryBudget(1);
+        List<Metadata> metadataList = getRecursiveMetadata(zip, 
digestContext(budget), false);
+        assertDigestsAndContent(metadataList, entries);
+        assertEquals(0, budget.getReservedBytes());
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/detect/zip/DefaultZipContainerDetector.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/detect/zip/DefaultZipContainerDetector.java
index 32eaff2fdc..20d26e06bd 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/detect/zip/DefaultZipContainerDetector.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/detect/zip/DefaultZipContainerDetector.java
@@ -19,8 +19,8 @@ package org.apache.tika.detect.zip;
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
+import java.nio.channels.Channels;
+import java.nio.channels.SeekableByteChannel;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -46,6 +46,7 @@ import org.apache.tika.metadata.Zip;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.ParsingIntent;
+import org.apache.tika.zip.utils.ZipFileHelper;
 import org.apache.tika.zip.utils.ZipSalvager;
 
 /**
@@ -213,7 +214,8 @@ public class DefaultZipContainerDetector implements 
Detector {
     }
 
     /**
-     * This will call TikaInputStream's getFile(). If there are no exceptions,
+     * Opens the zip via {@link ZipFileHelper#open} (random access from memory 
when the
+     * content is already cached there, a file otherwise). If there are no 
exceptions,
      * it will place the ZipFile in TikaInputStream's openContainer and leave 
it
      * open.
      * <p>
@@ -232,7 +234,7 @@ public class DefaultZipContainerDetector implements 
Detector {
         // Try to open ZipFile directly
         ZipFile zip = null;
         try {
-            zip = ZipFile.builder().setFile(tis.getFile()).get();
+            zip = ZipFileHelper.open(tis, null);
             metadata.set(Zip.DETECTOR_ZIPFILE_OPENED, true);
         } catch (IOException e) {
             // ZipFile failed to open (truncated/corrupt)
@@ -287,8 +289,8 @@ public class DefaultZipContainerDetector implements 
Detector {
         if (LOG.isDebugEnabled()) {
             LOG.debug("Falling back to streaming detection");
         }
-        try {
-            return detectStreamingFromPath(tis.getPath(), metadata, false);
+        try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+            return detectStreamingFromChannel(channel, metadata, false);
         } catch (IOException e) {
             //swallow
         }
@@ -328,11 +330,18 @@ public class DefaultZipContainerDetector implements 
Detector {
         return finalDetect(detectContext);
     }
 
-    private MediaType detectStreamingFromPath(Path p, Metadata metadata, 
boolean allowStoredEntries)
-            throws IOException {
+    /**
+     * Streaming detection over a seekable channel (rewound to 0 for each 
attempt), so
+     * in-memory content need not be spilled to a path. The channel is 
close-shielded from
+     * the ZipArchiveInputStream; the caller owns closing it.
+     */
+    private MediaType detectStreamingFromChannel(SeekableByteChannel channel, 
Metadata metadata,
+                                                 boolean allowStoredEntries) 
throws IOException {
+        channel.position(0);
         StreamingDetectContext detectContext = new StreamingDetectContext();
         try (ZipArchiveInputStream zis = new ZipArchiveInputStream(
-                Files.newInputStream(p), "UTF8", false, allowStoredEntries)) {
+                CloseShieldInputStream.wrap(Channels.newInputStream(channel)),
+                "UTF8", false, allowStoredEntries)) {
             ZipArchiveEntry zae = zis.getNextEntry();
             while (zae != null) {
                 MediaType mt = detect(zae, zis, detectContext);
@@ -346,7 +355,7 @@ public class DefaultZipContainerDetector implements 
Detector {
                     zfe.getFeature() == 
UnsupportedZipFeatureException.Feature.DATA_DESCRIPTOR) {
                 // Set hint for parser that DATA_DESCRIPTOR support is required
                 metadata.set(Zip.DETECTOR_DATA_DESCRIPTOR_REQUIRED, true);
-                return detectStreamingFromPath(p, metadata, true);
+                return detectStreamingFromChannel(channel, metadata, true);
             }
         } catch (SecurityException e) {
             throw e;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/zip/utils/ZipFileHelper.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/zip/utils/ZipFileHelper.java
new file mode 100644
index 0000000000..2caae570f5
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/zip/utils/ZipFileHelper.java
@@ -0,0 +1,51 @@
+/*
+ * 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.zip.utils;
+
+import java.io.IOException;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.charset.Charset;
+
+import org.apache.commons.compress.archivers.zip.ZipFile;
+
+import org.apache.tika.io.TikaInputStream;
+
+public class ZipFileHelper {
+
+    /**
+     * Opens a {@link ZipFile} over {@link 
TikaInputStream#getSeekableByteChannel()}, so
+     * in-memory content is read without spilling to disk. On success the 
returned
+     * {@code ZipFile.close()} closes the channel it was built on; on failure 
the channel
+     * is closed before the exception propagates.
+     */
+    public static ZipFile open(TikaInputStream tis, Charset charset) throws 
IOException {
+        SeekableByteChannel channel = tis.getSeekableByteChannel();
+        try {
+            ZipFile.Builder builder = 
ZipFile.builder().setSeekableByteChannel(channel);
+            if (charset != null) {
+                builder.setCharset(charset);
+            }
+            return builder.get();
+        } catch (IOException | RuntimeException e) {
+            channel.close();
+            throw e;
+        }
+    }
+
+    private ZipFileHelper() {
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/zip/utils/ZipSalvager.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/zip/utils/ZipSalvager.java
index ec30cb3dd6..4538117e75 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/zip/utils/ZipSalvager.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/zip/utils/ZipSalvager.java
@@ -57,7 +57,8 @@ public class ZipSalvager {
      *   <li>Returns null</li>
      * </ul>
      *
-     * @param tis      the TikaInputStream (must be file-backed)
+     * @param tis      the TikaInputStream (must be rewindable, i.e. unread or 
with rewind
+     *                 enabled; it need not be file-backed)
      * @param metadata the metadata to update with hints
      * @param charset  optional charset for entry names (may be null)
      * @return the opened ZipFile, or null if opening and salvaging both failed
@@ -65,11 +66,7 @@ public class ZipSalvager {
     public static ZipFile tryToOpenZipFile(TikaInputStream tis, Metadata 
metadata, Charset charset) {
         // First, try direct open
         try {
-            ZipFile.Builder builder = new 
ZipFile.Builder().setFile(tis.getFile());
-            if (charset != null) {
-                builder.setCharset(charset);
-            }
-            ZipFile zipFile = builder.get();
+            ZipFile zipFile = ZipFileHelper.open(tis, charset);
 
             // Direct open succeeded
             metadata.set(Zip.DETECTOR_ZIPFILE_OPENED, true);
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ParseHandler.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ParseHandler.java
index fab0a3f53d..e8aa9375fb 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ParseHandler.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ParseHandler.java
@@ -18,7 +18,7 @@ package org.apache.tika.pipes.core.server;
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.nio.file.Files;
+import java.nio.channels.Channels;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ArrayBlockingQueue;
@@ -163,7 +163,7 @@ class ParseHandler {
         if (unpackConfig != null &&
                 unpackConfig.isIncludeOriginal()) {
             UnpackHandler unpackHandler = 
parseContext.get(UnpackHandler.class);
-            try (InputStream is = Files.newInputStream(tis.getPath())) {
+            try (InputStream is = 
Channels.newInputStream(tis.getSeekableByteChannel())) {
                 unpackHandler.add(0, metadata, is);
             } catch (IOException e) {
                 LOG.warn("problem reading source file into embedded document 
byte store", e);
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index fc40d3beb1..c359f89227 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -52,6 +52,7 @@ import org.apache.tika.config.loader.TikaLoader;
 import org.apache.tika.detect.Detector;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.CacheMemoryBudget;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.filter.MetadataFilter;
 import org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
@@ -90,6 +91,51 @@ public class PipesServer implements AutoCloseable {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(PipesServer.class);
 
+    // Process-wide budget bounding total in-memory stream caching 
(embedded-object digest rewind
+    // buffers, etc.) so small embedded objects stay in RAM instead of 
spilling per-object at 1MB.
+    // NOTE: read in THIS (forked server) JVM -- set it via the config's 
forkedJvmArgs, not on the
+    // parent JVM. Tunable via -Dtika.pipes.cacheMemoryBudgetBytes; <=0 
disables (falls back to
+    // the 1MB per-object default). Clamped to a quarter of the fork's max 
heap.
+    static final String CACHE_MEMORY_BUDGET_BYTES_PROP = 
"tika.pipes.cacheMemoryBudgetBytes";
+
+    static final CacheMemoryBudget CACHE_MEMORY_BUDGET = 
initCacheMemoryBudget();
+
+    private static CacheMemoryBudget initCacheMemoryBudget() {
+        long bytes = 256L * 1024 * 1024;
+        String val = System.getProperty(CACHE_MEMORY_BUDGET_BYTES_PROP);
+        if (val != null) {
+            try {
+                bytes = Long.parseLong(val.trim());
+            } catch (NumberFormatException e) {
+                LOG.warn("Could not parse -D{}={} (plain bytes required, no 
unit suffix); " +
+                        "using the default {}", 
CACHE_MEMORY_BUDGET_BYTES_PROP, val, bytes);
+            }
+        }
+        if (bytes <= 0) {
+            LOG.info("Cache memory budget disabled; per-object 1MB spill 
threshold applies");
+            return null;
+        }
+        long clamp = Runtime.getRuntime().maxMemory() / 4;
+        if (bytes > clamp) {
+            LOG.warn("Cache memory budget {} exceeds a quarter of max heap; 
clamping to {}",
+                    bytes, clamp);
+            bytes = clamp;
+        }
+        LOG.info("Cache memory budget: {} bytes", bytes);
+        return new CacheMemoryBudget(bytes);
+    }
+
+    /**
+     * Seeds the process-wide cache memory budget into a merged per-request 
ParseContext.
+     * The budget is not a registered component, so neither the config nor a 
wire request
+     * can supply one -- this is the only way it enters a context in a pipes 
fork.
+     */
+    static void seedCacheMemoryBudget(ParseContext mergedContext) {
+        if (CACHE_MEMORY_BUDGET != null) {
+            mergedContext.set(CacheMemoryBudget.class, CACHE_MEMORY_BUDGET);
+        }
+    }
+
     public static final int AUTH_TOKEN_LENGTH_BYTES = 32;
 
     /** Env var the parent manager sets so the child can watch the parent's
@@ -677,6 +723,7 @@ public class PipesServer implements AutoCloseable {
         // EmbeddedDocumentExtractor + UnpackedByteCount in PipesWorker's 
UNPACK-mode setup.
         // Request-level values override config defaults
         mergedContext.copyFrom(requestContext);
+        seedCacheMemoryBudget(mergedContext);
         return mergedContext;
     }
 
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
index d1b5ed3a2c..a488a4922e 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
@@ -158,6 +158,7 @@ public class SharedServerResources {
         // content extraction for every non-UNPACK parse mode.
         // Request-level values override config defaults
         mergedContext.copyFrom(requestContext);
+        PipesServer.seedCacheMemoryBudget(mergedContext);
         return mergedContext;
     }
 
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/CacheMemoryBudgetSeedingTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/CacheMemoryBudgetSeedingTest.java
new file mode 100644
index 0000000000..f44cae7f5c
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/CacheMemoryBudgetSeedingTest.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.parser.ParseContext;
+
+public class CacheMemoryBudgetSeedingTest {
+
+    @Test
+    public void testDefaultBudgetClampedToHeap() {
+        // No -Dtika.pipes.cacheMemoryBudgetBytes in the surefire JVM -> the 
256MB default,
+        // clamped to a quarter of max heap
+        assertNotNull(PipesServer.CACHE_MEMORY_BUDGET);
+        long expected = Math.min(256L * 1024 * 1024, 
Runtime.getRuntime().maxMemory() / 4);
+        assertEquals(expected, PipesServer.CACHE_MEMORY_BUDGET.getMaxBytes());
+    }
+
+    @Test
+    public void testSeeding() {
+        ParseContext context = new ParseContext();
+        PipesServer.seedCacheMemoryBudget(context);
+        assertSame(PipesServer.CACHE_MEMORY_BUDGET, 
context.get(CacheMemoryBudget.class));
+    }
+}

Reply via email to