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 a6f2502858 Trivial fixes from the post-4.1.0d review (#3211)
a6f2502858 is described below

commit a6f2502858d7520bd9a9ecf262e005686f120fa6
Author: Tim Allison <[email protected]>
AuthorDate: Sat Sep 19 18:10:13 2026 -0400

    Trivial fixes from the post-4.1.0d review (#3211)
---
 .github/workflows/main-jdk17-build.yml             |   2 +-
 .skills/devs/development/SKILL.md                  |  24 ++
 docs/modules/ROOT/pages/configuration/index.adoc   |   9 +-
 .../parsers/tesseract-ocr-parser.adoc              |   2 +-
 .../pages/migration-to-4x/migrating-to-4x.adoc     |   4 +-
 docs/modules/ROOT/pages/pipes/cpu-sizing.adoc      |  20 +-
 docs/modules/ROOT/pages/pipes/performance.adoc     |  13 +-
 release-tools/uat/run-uat.sh                       |  16 +-
 .../java/org/apache/tika/config/JsonConfig.java    |   5 +
 .../org/apache/tika/io/BoundedInputStream.java     |  29 +-
 .../org/apache/tika/io/CachingInputStream.java     |  20 +-
 .../java/org/apache/tika/io/CachingSource.java     |  14 +
 .../main/java/org/apache/tika/io/FileSource.java   |  18 +-
 .../java/org/apache/tika/io/ReopenableSource.java  |  29 +-
 .../main/java/org/apache/tika/io/StreamCache.java  |  56 ++-
 .../java/org/apache/tika/io/TikaInputStream.java   |  15 +-
 .../java/org/apache/tika/parser/ParseContext.java  |   9 +-
 .../tika/parser/inference/InferenceDispatcher.java |  12 +-
 .../org/apache/tika/io/BoundedInputStreamTest.java |  68 ++++
 .../apache/tika/io/InMemoryContentViewTest.java    |   8 +-
 .../org/apache/tika/io/ReopenableSourceTest.java   |  20 +-
 .../tika/io/TikaInputStreamOwnershipTest.java      |  15 +-
 .../apache/tika/io/TikaInputStreamPathsTest.java   | 400 +++++++++++++++++++++
 .../org/apache/tika/parser/ParseContextTest.java   |  16 +
 .../parser/inference/InferenceDispatcherTest.java  |  36 ++
 .../java/org/apache/tika/http/TikaHttpClient.java  |   4 +
 .../tika/parser/ocr/tess4j/Tess4JConfig.java       |   2 +-
 .../tika/parser/ocr/tess4j/Tess4JConfigTest.java   |  11 +
 .../apache/tika/parser/vlm/AbstractVLMParser.java  |  22 +-
 .../tika/parser/vlm/MarkdownToXHTMLEmitter.java    |   7 +
 .../parser/vlm/MarkdownToXHTMLEmitterTest.java     |  10 +
 .../tika/parser/vlm/OpenAIVLMParserTest.java       |   9 +
 .../org/apache/tika/detect/ogg/OggDetector.java    |   6 +-
 .../org/apache/tika/parser/image/ImageParser.java  |  12 +-
 .../apache/tika/parser/image/RawTiffParser.java    |   3 +-
 .../tika/parser/image/RawTiffParserTest.java       |  12 +
 .../microsoft/MSEmbeddedStreamTranslator.java      |   8 +-
 .../microsoft/ooxml/AbstractOOXMLExtractor.java    |  14 +-
 .../apache/tika/parser/ocr/TesseractOCRParser.java |  27 +-
 .../parser/ocr/TesseractOCRConfigTrustTest.java    |  49 +++
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  |   6 +-
 .../apache/tika/parser/pdf/PDFPagesEmitTest.java   |  41 +++
 .../org/apache/tika/parser/pkg/SevenZParser.java   |  52 ++-
 .../java/org/apache/tika/parser/pkg/ZipParser.java |   5 +-
 .../tika/parser/pkg/PackageEmbeddedBudgetTest.java |  66 +++-
 .../serdes/ParseContextDeserializer.java           |   2 +-
 .../serdes/ParseContextTrustTest.java              |  36 ++
 tika-server/docker-build/docker-tool.sh            |   3 +-
 .../sample-configs/vision/vlm-claude.json          |   2 +-
 .../sample-configs/vision/vlm-gemini.json          |   2 +-
 .../tika/server/standard/TikaResourceTest.java     |   3 +-
 51 files changed, 1072 insertions(+), 202 deletions(-)

diff --git a/.github/workflows/main-jdk17-build.yml 
b/.github/workflows/main-jdk17-build.yml
index b084a79456..f0d9b8d0e6 100644
--- a/.github/workflows/main-jdk17-build.yml
+++ b/.github/workflows/main-jdk17-build.yml
@@ -72,7 +72,7 @@ jobs:
           key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
           restore-keys: maven-${{ runner.os }}-
       - name: Install external tools
-        run: sudo apt-get update && sudo apt-get install -y ffmpeg 
libimage-exiftool-perl
+        run: sudo apt-get update && sudo apt-get install -y ffmpeg 
libimage-exiftool-perl tesseract-ocr
       # Everything except $IT_MODULES, which the integration-tests job owns.
       # sed turns each ':artifactId' into the '!:artifactId' exclusion form.
       # rat runs in its own SERIAL invocation: apache-rat-plugin 0.18 is not
diff --git a/.skills/devs/development/SKILL.md 
b/.skills/devs/development/SKILL.md
index 176b12f492..43ee5377a5 100644
--- a/.skills/devs/development/SKILL.md
+++ b/.skills/devs/development/SKILL.md
@@ -166,6 +166,30 @@ personal configuration does not override it.
   legitimate exception: a path that is the data under test (e.g. an expected
   metadata value extracted from a test document) — leave those untouched.
 
+## NEVER: two mistakes that ship as vulnerabilities
+
+**Never trust a length that came from user input or from inside a file.** 
`Content-Length`
+on a request, a zip or 7z entry's declared size, an OLE/OOXML part size, a PDF 
embedded
+file's `/Size`, an ISO box length, a count line in a mail file: every one of 
them is an
+untrusted number. Never allocate, reserve, `mark()`, `skip()` or bound a loop 
by it as if it
+were true. A lie small truncates content silently; a lie large lets one 
document allocate the
+heap or a budget, or spin a reader waiting for bytes that never come. Use a 
declared length
+only to *decline* work (a size the budget cannot cover is not attempted) or as 
a hint that the
+actual read corrects; grow from a floor and reserve what was actually read; 
treat the measured
+end of the stream as the only ground truth (`hasReliableLength()`), and 
overwrite the claim
+with it. See TIKA-4908 and the 2026-09-19 stream audit for what this cost.
+
+**Never call `InputStream.skip()` on a stream that could be a 
`FileInputStream` (or a
+`BufferedInputStream` over one) and trust the answer.** 
`FileInputStream.skip(n)` seeks past
+EOF and returns `n` even when no bytes exist, so a reader that skips a payload 
and then reads
+the next header believes it is somewhere it is not, and loops or mis-parses 
forever;
+`BufferedInputStream.skip` can return 0 before EOF, which spins any `while 
(remaining > 0)
+remaining -= skip(remaining)` loop. Skip by reading (`IOUtils.skip` / 
`IOUtils.skipFully`) or
+seek on a channel or an in-memory buffer whose size you own; 
`TikaInputStream.skip`,
+`BoundedInputStream.skip` and every source's `seekTo` read for this reason. Do 
not "optimize"
+them back. Anything hot enough to need a real seek gets a channel from
+`getSeekableByteChannel()`, never a raw `skip`.
+
 ## Test Discipline
 
 - A behavioral change gets a regression test that fails without it.  Where
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index 3d6aa1d0eb..2867cbf523 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -282,8 +282,13 @@ stops the server at startup rather than reaching a service 
as an empty key. A li
 before.
 
 The forked parse workers of tika-server and Pipes inherit the parent's 
environment and read the same
-config file, so a reference resolves there too. Per-request configuration 
(`/config` endpoints,
-presets, pipes tuples) is never interpolated: a request cannot read the 
server's environment.
+config file, so a reference resolves there too. Presets written inline in the 
config file resolve
+at startup with the rest of it; catalog presets and per-request configuration 
(`/config`
+endpoints, pipes tuples) never do: a request cannot read the server's 
environment.
+
+A reference keeps a secret out of tika-config.json, not out of every file: 
with a persistent
+config store (`configStoreType` `file` or Ignite) the resolved fetcher and 
emitter configurations
+are written to that store. The default in-memory store writes nothing.
 
 == Windows file paths
 
diff --git 
a/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
index e1eb49aaab..b61d6b6452 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
@@ -73,7 +73,7 @@ include::example$tesseract-full.json[]
 icon:github[] 
https://github.com/apache/tika/blob/main/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/tesseract-full.json[View
 source on GitHub]
 
 Arbitrary Tesseract variables go in `otherTesseractConfig` as a name-to-value 
map, as in the
-example above. The map is server-side configuration only: a per-request 
`tesseract-ocr-parser`
+example above. The map is operator configuration only (tika-config.json, 
including its presets): a per-request `tesseract-ocr-parser`
 block that carries it is rejected, because variables such as `debug_file` name 
files the
 tesseract binary opens. Converting a 3.x config? See
 xref:migration-to-4x/migrating-to-4x.adoc[Migrating to 4.x] — the automatic 
converter rewrites the
diff --git a/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc
index 7f67aa39e3..28b7fd7d44 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc
@@ -300,8 +300,8 @@ change with the aliases: page renders are emitted at the 
end of each page whiche
 and a page the parser does not read (`maxPages`) is not rendered. Two floors 
are new: a page
 narrower or shorter than 2 pixels at the target dpi is not rendered, and an 
image under 2 x 2
 pixels reaches no text recognizer or inference binding 
(`pages.render.minWidth`/`minHeight`,
-`_min-width`/`_min-height` on a recognizer entry, `minWidth`/`minHeight` on a 
binding; 0 restores
-4.0). A per-request `ocr` block now changes only the fields it names (4.0 
replaced the whole
+`_min-width`/`_min-height` on a recognizer entry, `minWidth`/`minHeight` on a 
binding). A binding's
+`minWidth: 0` restores 4.0; the 2-pixel floor before the recognizers stays 
whatever the entry says. A per-request `ocr` block now changes only the fields 
it names (4.0 replaced the whole
 block). In Java, `PDFParserConfig.getOcr()` and `getImageStrategy()` are gone: 
`getPages()` is
 the overlay, the aliases write to it, the deprecated setters stay.
 
diff --git a/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc 
b/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc
index 5dbedc88b7..8e2208eac7 100644
--- a/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc
+++ b/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc
@@ -59,12 +59,12 @@ PARENT_RESERVED_CORES = 2
 MIN_AUTO_CAP_SLICE    = 2
 ----
 
-If `slice ≥ 2`, Tika injects `-XX:ActiveProcessorCount=<slice>` into each
-forked JVM. If `slice < 2`, the auto-cap is *skipped* and a `WARN` is
-logged advising the operator to lower `numClients`. Skipping is intentional:
-at `slice=1` the fork's only CPU is fully consumed by parsing, so its
-socket-reader thread cannot run and the parent's writes block on
-receiver-side back-pressure — measurably worse than no cap at all.
+Tika injects `-XX:ActiveProcessorCount=<slice>` into each forked JVM. A fair
+share below 2 is clamped to 2 rather than left uncapped: at `slice=1` the
+fork's only CPU is fully consumed by parsing, so its socket-reader thread
+cannot run, and with no cap at all every fork sizes its GC and JIT pools for
+the whole host. The clamp is logged as `slice=2 (clamped; fair share would be
+N)`, and with more than one client a `WARN` advises lowering `numClients`.
 
 == Recommended sizing
 
@@ -74,14 +74,14 @@ For typical cloud-VM core counts:
 |===
 |hostCores |numClients |slice |Notes
 
-|2  |1 |1 → skipped |Tight; auto-cap declines. Acceptable for low throughput.
+|2  |1 |1 → 2 (clamped) |Tight. Acceptable for low throughput.
 |4  |1 |2      |Comfortable single-fork deployment.
-|4  |2 |1 → skipped |Auto-cap declines; consider `numClients=1`.
+|4  |2 |1 → 2 (clamped) |Over-provisioned; consider `numClients=1`.
 |8  |1 |6      |Lots of headroom; single-fork lifecycle isolation is fine.
 |8  |3 |2      |Sweet spot for medium pods.
 |16 |4 |3      |Sweet spot for 16-core hosts. Measured winner in benchmarks.
 |16 |6 |2      |Higher concurrency; tighter per-fork breathing room.
-|16 |8 |1 → skipped |Doesn't fit 16 cores. Keep at 4 or 6.
+|16 |8 |1 → 2 (clamped) |Doesn't fit 16 cores. Keep at 4 or 6.
 |32 |8 |3      |Same shape as 16/4.
 |===
 
@@ -122,7 +122,7 @@ INFO  pipes-cpu-sizing: hostCores=16, numClients=4, 
parentReserved=2, autoCap=sl
 The `autoCap` field is one of:
 
 * `slice=N` — the auto-cap fired; each fork sees N CPUs.
-* `skipped (slice<2)` — over-provisioned; operator should reduce `numClients`.
+* `slice=2 (clamped; fair share would be N)` — over-provisioned; reduce 
`numClients`.
 * `user-set in forkedJvmArgs` — operator set `-XX:ActiveProcessorCount` 
themselves.
 
 Two `WARN`-level messages call out clearly-bad provisioning:
diff --git a/docs/modules/ROOT/pages/pipes/performance.adoc 
b/docs/modules/ROOT/pages/pipes/performance.adoc
index 2ad85cb059..525c592a43 100644
--- a/docs/modules/ROOT/pages/pipes/performance.adoc
+++ b/docs/modules/ROOT/pages/pipes/performance.adoc
@@ -241,11 +241,10 @@ outputs on the same pair of spindles. The corpus is far 
larger than RAM, so
 every run is effectively cold-cache. The configuration we settled on:
 
 * **Per-client mode, `numClients = 7`.** Tika auto-injects
-  `-XX:ActiveProcessorCount` per fork as `(cores − 2) / numClients` but only
-  when that slice is at least 2. On 16 logical cores, 8 workers give 1.75 and
-  the cap is *skipped* — eight JVMs each sizing GC and JIT for 16 cores, which
-  is what the 7.5 h run did. Seven workers get 2 cores each and the cap
-  applies. Per-client cost about 7% versus shared-server here (29.0 min vs
+  `-XX:ActiveProcessorCount` per fork as `(cores − 2) / numClients`, clamped
+  at 2. Before 4.1.0 a share below 2 switched the cap off: on 16 logical
+  cores, 8 workers gave 1.75 and eight JVMs each sized GC and JIT for 16
+  cores, which is what the 7.5 h run did. Seven workers get 2 cores each. 
Per-client cost about 7% versus shared-server here (29.0 min vs
   27.2 min in the controlled study above)
   and dropped no files, where the shared worker loses the in-flight documents
   of every other client when one document crashes it.
@@ -271,8 +270,8 @@ box:
   cache (or use a subset you have not touched) and write outputs to the real
   destination. Warm-cache runs with outputs on tmpfs hid this entire problem
   from us for weeks.
-* **Does your `numClients` trip the cap skip?** Check the startup log for the
-  `ActiveProcessorCount` decision; if it reports `skipped`, lower
+* **Is your `numClients` over-provisioned?** Check the startup log for the
+  `ActiveProcessorCount` decision; if it reports `clamped`, lower
   `numClients` or set the cap yourself in `forkedJvmArgs`.
 
 Beyond those: fix concurrency equal to the worker count when comparing, exclude
diff --git a/release-tools/uat/run-uat.sh b/release-tools/uat/run-uat.sh
index 0e924c7ab5..2ad0923b6f 100755
--- a/release-tools/uat/run-uat.sh
+++ b/release-tools/uat/run-uat.sh
@@ -300,17 +300,23 @@ if [[ -n "$INFERENCE" ]]; then
 
   # T44: per-request switches (allowPerRequestConfig is on in the UAT config): 
the
   # recognizer off removes the OCR text; a bindings subset runs only that 
binding.
-  OFF=$(curl -s -X POST -F "file=@$OCRPNG" \
+  OFF=$(curl -s -o /tmp/uat-t44.out -w '%{http_code}' -X POST -F 
"file=@$OCRPNG" \
         -F 'config={"parse-context":{"text-recognizers":{"enabled":false}}}' 
"$BASE/tika/config/text")
-  if printf '%s' "$OFF" | grep -qF "The quick brown fox"; then
+  if [[ "$OFF" != "200" ]]; then
+    bad "T44 text-recognizers.enabled=false per request" "HTTP 200" "HTTP $OFF"
+  elif grep -qF "The quick brown fox" /tmp/uat-t44.out; then
     bad "T44 text-recognizers.enabled=false per request" "no OCR text" "OCR 
text present"
   else
     ok "T44 text-recognizers.enabled=false per request"
   fi
-  SUBSET=$(curl -s -X POST -F "file=@$OCRPNG" \
+  SUBSET_CODE=$(curl -s -o /tmp/uat-t45.out -w '%{http_code}' -X POST -F 
"file=@$OCRPNG" \
         -F 'config={"parse-context":{"inference":{"bindings":["text"]}}}' \
         -H 'Accept: application/json' "$BASE/rmeta/config")
-  if printf '%s' "$SUBSET" | grep -qF '\"producer\":\"pictures\"'; then
+  if [[ "$SUBSET_CODE" != "200" ]]; then
+    bad "T45 inference.bindings subset per request" "HTTP 200" "HTTP 
$SUBSET_CODE"
+  elif ! grep -qF '\"producer\":\"text\"' /tmp/uat-t45.out; then
+    bad "T45 inference.bindings subset per request" "text chunk present" "no 
text chunk"
+  elif grep -qF '\"producer\":\"pictures\"' /tmp/uat-t45.out; then
     bad "T45 inference.bindings subset per request" "no pictures chunk" 
"pictures binding ran"
   else
     ok "T45 inference.bindings subset per request"
@@ -329,6 +335,8 @@ if [[ -n "$INFERENCE" ]]; then
     else
       skip "T46 MEDIA bindings -- no segment vectors (ffmpeg not available on 
server)"
     fi
+  elif [[ "${TIKA_UAT_REQUIRE_MEDIA:-}" == "1" ]]; then
+    bad "T46 MEDIA bindings" "$VIDEO present (TIKA_UAT_REQUIRE_MEDIA=1)" 
"missing"
   else
     skip "T46 MEDIA bindings -- $VIDEO missing"
   fi
diff --git a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java 
b/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
index 7d4d1eadaa..ab507c5d86 100644
--- a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
+++ b/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
@@ -35,4 +35,9 @@ public interface JsonConfig {
      * @return the JSON configuration
      */
     String json();
+
+    /** Operator-authored (config file, presets), as opposed to per-request 
input. */
+    default boolean trusted() {
+        return false;
+    }
 }
diff --git a/tika-core/src/main/java/org/apache/tika/io/BoundedInputStream.java 
b/tika-core/src/main/java/org/apache/tika/io/BoundedInputStream.java
index 0155bd40c7..456963cee7 100644
--- a/tika-core/src/main/java/org/apache/tika/io/BoundedInputStream.java
+++ b/tika-core/src/main/java/org/apache/tika/io/BoundedInputStream.java
@@ -36,6 +36,7 @@ public class BoundedInputStream extends InputStream {
     private final long max;
     private final InputStream in;
     private long pos;
+    private long markPos;
 
     public BoundedInputStream(long max, InputStream in) {
         this.max = max;
@@ -93,19 +94,14 @@ public class BoundedInputStream extends InputStream {
         return bytesRead;
     }
 
-    /**
-     * Invokes the delegate's <code>skip(long)</code> method.
-     * As with InputStream generally, this does not guarantee reading n bytes.
-     * Use IOUtils' skipFully for that functionality.
-     *
-     * @param n the number of bytes to skip
-     * @return the actual number of bytes skipped
-     * @throws IOException if an I/O error occurs
-     */
+    /** Reads, never {@code in.skip()}: FileInputStream seeks past EOF and 
reports the full count. */
     @Override
     public long skip(final long n) throws IOException {
         final long toSkip = max >= 0 ? Math.min(n, max - pos) : n;
-        final long skippedBytes = in.skip(toSkip);
+        if (toSkip <= 0) {
+            return 0;
+        }
+        final long skippedBytes = IOUtils.skip(in, toSkip);
         pos += skippedBytes;
         return skippedBytes;
     }
@@ -113,28 +109,19 @@ public class BoundedInputStream extends InputStream {
     @Override
     public void reset() throws IOException {
         in.reset();
-        pos = 0;
+        pos = markPos;
     }
 
     @Override
     public void mark(int readLimit) {
         in.mark(readLimit);
+        markPos = pos;
     }
 
     public boolean hasHitBound() {
         return pos >= max;
     }
 
-    @Override
-    public byte[] readNBytes(int len) throws IOException {
-        return in.readNBytes(len);
-    }
-
-    @Override
-    public int readNBytes(byte[] b, int off, int len) throws IOException {
-        return in.readNBytes(b, off, len);
-    }
-
     @Override
     public int available() throws IOException {
         return in.available();
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 4d743f00e1..c919b3620d 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
@@ -74,6 +74,7 @@ class CachingInputStream extends InputStream {
             cache.append(buffer, 0, n);
         }
         sourceExhausted = true;
+        cache.trim();
     }
 
     @Override
@@ -95,6 +96,7 @@ class CachingInputStream extends InputStream {
         int b = source.read();
         if (b == -1) {
             sourceExhausted = true;
+        cache.trim();
             return -1;
         }
 
@@ -129,6 +131,7 @@ class CachingInputStream extends InputStream {
             int n = source.read(b, off, len);
             if (n == -1) {
                 sourceExhausted = true;
+        cache.trim();
             } else if (n > 0) {
                 cache.append(b, off, n);
                 position += n;
@@ -144,13 +147,16 @@ class CachingInputStream extends InputStream {
         if (n <= 0) {
             return 0;
         }
-
-        // We need to actually read the bytes to cache them
         long skipped = 0;
-        byte[] buffer = new byte[4096];
+        long cached = cache.size() - position;
+        if (cached > 0) {
+            skipped = Math.min(n, cached);
+            position += skipped;
+        }
+        // beyond the cache the bytes must be read to be cached
+        byte[] buffer = new byte[8192];
         while (skipped < n) {
-            int toRead = (int) Math.min(buffer.length, n - skipped);
-            int read = read(buffer, 0, toRead);
+            int read = read(buffer, 0, (int) Math.min(buffer.length, n - 
skipped));
             if (read == -1) {
                 break;
             }
@@ -187,6 +193,10 @@ class CachingInputStream extends InputStream {
         return cache.size();
     }
 
+    boolean isSourceExhausted() {
+        return sourceExhausted;
+    }
+
     /**
      * Force all remaining content to be read and cached, then return the file 
path.
      */
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 d77b86779b..fc43d1bf4b 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
@@ -95,6 +95,8 @@ class CachingSource extends InputStream implements 
TikaInputSource {
         int b = passthroughStream.read();
         if (b != -1) {
             passthroughPosition++;
+        } else {
+            measured(passthroughPosition);
         }
         return b;
     }
@@ -114,6 +116,8 @@ class CachingSource extends InputStream implements 
TikaInputSource {
         int n = passthroughStream.read(b, off, len);
         if (n > 0) {
             passthroughPosition += n;
+        } else if (n == -1) {
+            measured(passthroughPosition);
         }
         return n;
     }
@@ -330,13 +334,23 @@ class CachingSource extends InputStream implements 
TikaInputSource {
         return spilledPath;
     }
 
+    /** The end of the source is ground truth, over a declared length either 
way. */
+    private void measured(long total) {
+        length = total;
+        lengthMeasured = true;
+    }
+
     @Override
     public long getLength() {
+        if (!lengthMeasured && cachingStream != null && 
cachingStream.isSourceExhausted()) {
+            measured(cachingStream.getCachedSize());
+        }
         return length;
     }
 
     @Override
     public boolean hasReliableLength() {
+        getLength();
         return lengthMeasured;
     }
 
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 a57abf7ba9..babb114604 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
@@ -87,12 +87,9 @@ class FileSource extends InputStream implements 
TikaInputSource {
             throw new IOException("Cannot seek past end of file. Position: " +
                     newPosition + ", length: " + length);
         }
-
-        // Close current stream and reopen at the beginning
         currentStream.close();
         currentStream = new BufferedInputStream(Files.newInputStream(path));
-
-        // Skip to the new position
+        markInStream = false;
         if (newPosition > 0) {
             IOUtils.skipFully(currentStream, newPosition);
         }
@@ -143,11 +140,15 @@ class FileSource extends InputStream implements 
TikaInputSource {
     }
 
     // Mark/reset support using seekTo
+    private static final int MAX_BUFFERED_MARK = 1024 * 1024;
     private long markPosition = -1;
+    private boolean markInStream;
 
     @Override
     public synchronized void mark(int readlimit) {
         markPosition = position;
+        currentStream.mark(Math.min(readlimit, MAX_BUFFERED_MARK));
+        markInStream = true;
     }
 
     @Override
@@ -155,6 +156,15 @@ class FileSource extends InputStream implements 
TikaInputSource {
         if (markPosition < 0) {
             throw new IOException("Mark not set");
         }
+        if (markInStream) {
+            try {
+                currentStream.reset();   // within the limit the buffer holds 
the mark
+                position = markPosition;
+                return;
+            } catch (IOException e) {
+                // past it: reopen
+            }
+        }
         seekTo(markPosition);
     }
 
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
index bf55ca44c5..ccd49cd4f8 100644
--- a/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java
+++ b/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java
@@ -109,7 +109,12 @@ class ReopenableSource extends InputStream implements 
TikaInputSource {
         InputStream in = new BufferedInputStream(
                 spilledPath != null ? Files.newInputStream(spilledPath) : 
opener.get());
         if (at > 0) {
-            IOUtils.skipFully(in, at);
+            try {
+                IOUtils.skipFully(in, at);
+            } catch (IOException e) {
+                in.close();
+                throw e;
+            }
         }
         return in;
     }
@@ -177,6 +182,9 @@ class ReopenableSource extends InputStream implements 
TikaInputSource {
             try (OutputStream out = Files.newOutputStream(p)) {
                 if (retainedBuffer != null) {
                     out.write(retainedBuffer, 0, retainedLength);
+                    if (channelPins == 0) {
+                        dropRetained();   // the file serves every read from 
here on
+                    }
                 } else {
                     try (InputStream in = opener.get()) {
                         IOUtils.copy(in, out);
@@ -203,7 +211,7 @@ class ReopenableSource extends InputStream implements 
TikaInputSource {
 
     @Override
     public void enableRewind(CacheMemoryBudget budget) throws IOException {
-        if (position != 0) {
+        if (position != 0 && retainedBuffer == null && spilledPath == null) {
             throw new IOException("Cannot enable rewind: position is " + 
position +
                     ", must be 0. Call enableRewind() before reading.");
         }
@@ -225,16 +233,10 @@ class ReopenableSource extends InputStream implements 
TikaInputSource {
         if (retainedBuffer != null) {
             return true;
         }
-        if (spilledPath != null || closed || length < 0 || length > 
MAX_ARRAY_SIZE) {
+        if (spilledPath != null || closed || length > MAX_ARRAY_SIZE) {
             return false;
         }
-        if (length > IN_MEMORY_FLOOR) {
-            if (budget == null ||
-                    budget.getMaxBytes() - budget.getReservedBytes() < length 
- IN_MEMORY_FLOOR) {
-                return false;
-            }
-        }
-        if (!tryBufferInMemory()) {
+        if (!mayFitInMemory() || !tryBufferInMemory()) {
             return false;
         }
         if (currentStream != null) {
@@ -286,12 +288,19 @@ class ReopenableSource extends InputStream implements 
TikaInputSource {
      * grows, reserving, on what is actually read. Does not disturb this 
source's read
      * position.
      */
+    /** A declared length the budget cannot cover is not attempted; unknown is 
attempted. */
+    private boolean mayFitInMemory() {
+        return length <= IN_MEMORY_FLOOR || (budget != null
+                && budget.getMaxBytes() - budget.getReservedBytes() >= length 
- IN_MEMORY_FLOOR);
+    }
+
     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)
+        // never sized from the declared length past the floor: it is the 
file's claim
         byte[] data = new byte[(int) Math.max(8192, Math.min(length, 
IN_MEMORY_FLOOR))];
         int total = 0;
         boolean fits = false;
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 606910abcd..f5f78a8091 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
@@ -25,6 +25,7 @@ import java.io.RandomAccessFile;
 import java.nio.channels.SeekableByteChannel;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Arrays;
 
 /**
  * Package-private cache that stores bytes in memory up to a threshold,
@@ -57,6 +58,10 @@ class StreamCache implements Closeable {
     private Path spillFile;
     // one long-lived read handle; opening per read cost ~37x on 
byte-at-a-time readers
     private RandomAccessFile reader;
+    // a window over the spill file: byte-at-a-time readers otherwise pay a 
seek and a read each
+    private final byte[] window = new byte[8192];
+    private long windowStart = -1;
+    private int windowLength;
     private OutputStream spillOutputStream;
     private long totalSize;
 
@@ -217,12 +222,18 @@ class StreamCache implements Closeable {
 
         if (memoryBuffer != null) {
             return memoryBuffer[(int) position] & 0xFF;
-        } else {
+        }
+        if (position < windowStart || position >= windowStart + windowLength) {
             flushSpillStream();
             RandomAccessFile raf = reader();
             raf.seek(position);
-            return raf.read();
+            windowLength = Math.max(0, raf.read(window, 0, window.length));
+            windowStart = position;
+            if (windowLength == 0) {
+                return -1;
+            }
         }
+        return window[(int) (position - windowStart)] & 0xFF;
     }
 
     /**
@@ -241,11 +252,27 @@ class StreamCache implements Closeable {
         if (memoryBuffer != null) {
             System.arraycopy(memoryBuffer, (int) position, b, off, available);
             return available;
-        } else {
-            flushSpillStream();
-            RandomAccessFile raf = reader();
-            raf.seek(position);
-            return raf.read(b, off, available);
+        }
+        if (position >= windowStart && position + available <= windowStart + 
windowLength) {
+            System.arraycopy(window, (int) (position - windowStart), b, off, 
available);
+            return available;
+        }
+        flushSpillStream();
+        RandomAccessFile raf = reader();
+        raf.seek(position);
+        return raf.read(b, off, available);
+    }
+
+    /** The drain is complete: give the doubling slack back to the budget. */
+    void trim() {
+        if (memoryBuffer == null || memoryBuffer.length - memorySize <= 8192) {
+            return;
+        }
+        memoryBuffer = Arrays.copyOf(memoryBuffer, Math.max(memorySize, 1));
+        long target = Math.max(0, (long) memoryBuffer.length - 
memoryThreshold);
+        if (budget != null && reserved > target) {
+            budget.release(reserved - target);
+            reserved = target;
         }
     }
 
@@ -343,16 +370,11 @@ class StreamCache implements Closeable {
         closed = true;
         memoryBuffer = null;
         maybeReleaseReserved();
-
-        if (spillOutputStream != null) {
-            spillOutputStream.close();
-            spillOutputStream = null;
-        }
-        if (reader != null) {
-            reader.close();
-            reader = null;
-        }
-        // spillFile cleanup is handled by TemporaryResources
+        OutputStream out = spillOutputStream;
+        RandomAccessFile raf = reader;
+        spillOutputStream = null;
+        reader = null;
+        TemporaryResources.closeAll(out, raf);   // both, even if one throws; 
file deletion is tmp's
     }
 
     /**
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 09e6148f60..fcce527435 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
@@ -105,6 +105,9 @@ public class TikaInputStream extends TaggedInputStream {
         if (stream instanceof TikaInputStream) {
             return (TikaInputStream) stream;
         }
+        if (tmp == null) {
+            tmp = new TemporaryResources();
+        }
         String ext = getExtension(metadata);
         TikaInputSource inputSource =
                 new CachingSource(stream, tmp, declaredLength(metadata), 
metadata, ext);
@@ -331,6 +334,10 @@ public class TikaInputStream extends TaggedInputStream {
      */
     @Override
     public long skip(long n) throws IOException {
+        if (n <= 0) {
+            return 0;
+        }
+        // reads, never in.skip(): FileInputStream.skip seeks past EOF and 
reports the full count
         long skipped = IOUtils.skip(in, n);
         position += skipped;
         return skipped;
@@ -586,7 +593,7 @@ public class TikaInputStream extends TaggedInputStream {
      * 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
+     * need a {@link java.io.File}. Closed with the stream if the caller does 
not close it first. Does not
      * disturb this stream's read position.
      *
      * @throws IOException if this stream has been partially read without 
rewind enabled
@@ -596,7 +603,11 @@ public class TikaInputStream extends TaggedInputStream {
         if (source == null) {
             throw new IOException("No TikaInputSource available");
         }
-        return source.getSeekableByteChannel();
+        SeekableByteChannel channel = source.getSeekableByteChannel();
+        if (tmp != null) {
+            tmp.addResource(channel);   // a caller that throws before closing 
it must not leak it
+        }
+        return channel;
     }
 
     /**
diff --git a/tika-core/src/main/java/org/apache/tika/parser/ParseContext.java 
b/tika-core/src/main/java/org/apache/tika/parser/ParseContext.java
index 19c17eb046..f82d1ddd9b 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/ParseContext.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/ParseContext.java
@@ -142,14 +142,19 @@ public class ParseContext implements Serializable {
      * @since Apache Tika 4.0
      */
     public void setJsonConfig(String name, String json) {
-        setJsonConfig(name, json != null ? new StringJsonConfig(json) : null);
+        setJsonConfig(name, json, false);
+    }
+
+    /** {@code trusted}: operator-authored (config file, presets), never 
per-request input. */
+    public void setJsonConfig(String name, String json, boolean trusted) {
+        setJsonConfig(name, json != null ? new StringJsonConfig(json, trusted) 
: null);
     }
 
     /**
      * A simple Serializable implementation of JsonConfig that holds a JSON 
string.
      * This is used internally to ensure JSON configs can be serialized via 
Java serialization.
      */
-    private record StringJsonConfig(String json) implements JsonConfig, 
Serializable {
+    private record StringJsonConfig(String json, boolean trusted) implements 
JsonConfig, Serializable {
         private static final long serialVersionUID = 1L;
     }
 
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
index fc87d0c45c..4187d25e36 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
@@ -63,6 +63,7 @@ import org.apache.tika.parser.hook.ParseHooks;
  */
 public final class InferenceDispatcher implements ParseHook, 
TransientParseState {
 
+    static final int MAX_MEDIA_UNITS = 1000;
     private static final Logger LOG = 
LoggerFactory.getLogger(InferenceDispatcher.class);
     private static final InferenceSelection ALL = new InferenceSelection();
 
@@ -170,17 +171,24 @@ public final class InferenceDispatcher implements 
ParseHook, TransientParseState
             if (!runs(binding, context) || !binding.accepts(kind, type, 
target)) {
                 continue;
             }
-            // a media unit is the whole file; its maxBytes bounds one 
segment, in the task
+            // a media unit is the whole file; maxBytes and maxChunks bound 
its segments, in the task
             if (kind != InputKind.MEDIA && binding.getMaxBytes() >= 0 && size 
> binding.getMaxBytes()) {
                 state.dropped.merge(binding.getId() + " over maxBytes", 1, 
Integer::sum);
                 continue;
             }
             List<InferenceUnit> units = 
state.byBinding.computeIfAbsent(binding.getId(),
                     k -> new ArrayList<>());
-            if (binding.getMaxChunks() >= 0 && units.size() >= 
binding.getMaxChunks()) {
+            if (kind != InputKind.MEDIA && binding.getMaxChunks() >= 0
+                    && units.size() >= binding.getMaxChunks()) {
                 state.dropped.merge(binding.getId() + " over maxChunks", 1, 
Integer::sum);
                 continue;
             }
+            // a media unit is a held copy of a whole file; bound how many one 
tree may hold
+            if (kind == InputKind.MEDIA && units.size() >= MAX_MEDIA_UNITS) {
+                state.dropped.merge(binding.getId() + " over " + 
MAX_MEDIA_UNITS + " media files",
+                        1, Integer::sum);
+                continue;
+            }
             if (unit == null) {
                 ParseHooks.Run run = context.get(ParseHooks.Run.class);
                 unit = new InferenceUnit(kind, type, target, parent, 
idPath(target, run),
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/BoundedInputStreamTest.java 
b/tika-core/src/test/java/org/apache/tika/io/BoundedInputStreamTest.java
new file mode 100644
index 0000000000..a0226425a9
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/io/BoundedInputStreamTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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 java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+public class BoundedInputStreamTest {
+
+    /** A skip that lies (FileInputStream seeks past EOF) must not move the 
bound's position. */
+    @Test
+    public void skipReadsAndStaysWithinTheBound() throws Exception {
+        byte[] data = "0123456789".getBytes(StandardCharsets.US_ASCII);
+        InputStream lying = new ByteArrayInputStream(data) {
+            @Override
+            public long skip(long n) {
+                return n;   // claims to skip whatever is asked, like a 
FileInputStream past EOF
+            }
+        };
+        BoundedInputStream bis = new BoundedInputStream(5, lying);
+        assertEquals(3, bis.skip(3));
+        assertEquals('3', bis.read());
+        assertEquals(1, bis.skip(100), "bounded: only one byte remains under 
the limit");
+        assertEquals(-1, bis.read());
+
+        bis = new BoundedInputStream(8, new ByteArrayInputStream(data));
+        bis.read();
+        bis.mark(10);
+        bis.read();
+        bis.read();
+        bis.reset();
+        assertEquals('1', bis.read(), "reset returns to the marked position");
+        assertEquals(6, bis.skip(100), "and the bound counts from the mark, 
not from zero");
+    }
+
+    @Test
+    public void readNBytesHonorsTheBound() throws Exception {
+        byte[] data = "0123456789".getBytes(StandardCharsets.US_ASCII);
+        BoundedInputStream bis = new BoundedInputStream(5, new 
ByteArrayInputStream(data));
+        assertEquals("01234", new String(bis.readNBytes(100), 
StandardCharsets.US_ASCII));
+        assertEquals(-1, bis.read());
+
+        bis = new BoundedInputStream(5, new ByteArrayInputStream(data));
+        byte[] buf = new byte[10];
+        assertEquals(5, bis.readNBytes(buf, 0, 10));
+        assertEquals(0, bis.readNBytes(buf, 5, 5));
+        assertEquals("01234", new String(buf, 0, 5, 
StandardCharsets.US_ASCII));
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/InMemoryContentViewTest.java 
b/tika-core/src/test/java/org/apache/tika/io/InMemoryContentViewTest.java
index 5f711113ef..274a0c83ec 100644
--- a/tika-core/src/test/java/org/apache/tika/io/InMemoryContentViewTest.java
+++ b/tika-core/src/test/java/org/apache/tika/io/InMemoryContentViewTest.java
@@ -185,10 +185,12 @@ public class InMemoryContentViewTest {
             SeekableByteChannel channel = tis.getSeekableByteChannel();
             assertNotNull(TikaInputStream.inMemoryContent(channel), "2MB under 
a 64MB budget stays in memory");
             assertTrue(budget.getReservedBytes() > 0);
-            tis.close();
-            assertTrue(budget.getReservedBytes() > 0, "open channel still pins 
the reservation");
             channel.close();
-            assertEquals(0, budget.getReservedBytes(), "last close releases");
+            assertTrue(budget.getReservedBytes() > 0, "the live cache keeps 
its reservation");
+            SeekableByteChannel again = tis.getSeekableByteChannel();
+            tis.close();
+            assertFalse(again.isOpen(), "a channel is the stream's to close");
+            assertEquals(0, budget.getReservedBytes(), "closing the stream 
releases, pin or no pin");
         }
     }
 
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
index 49bdc99443..423945595f 100644
--- a/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
+++ b/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
@@ -107,17 +107,15 @@ public class ReopenableSourceTest {
     }
 
     @Test
-    public void testRetainInMemoryRefusedWhenLengthUnknown() throws Exception {
-        byte[] data = data(8192);
-        AtomicInteger opens = new AtomicInteger();
-        ReopenableSource source = new ReopenableSource(countingOpener(data, 
opens), tmp, -1, ".bin");
-        try {
-            assertFalse(source.tryRetainInMemory());
-            assertEquals(0, opens.get());
-            assertArrayEquals(data, readFully(source));
-        } finally {
-            source.close();
-        }
+    public void testRetainInMemoryWithUnknownLength() throws Exception {
+        // under the floor it is held; over it, with no budget, the attempt 
stops at the floor
+        byte[] small = new byte[64 * 1024];
+        ReopenableSource fits = new ReopenableSource(() -> new 
ByteArrayInputStream(small), null, -1, "");
+        assertTrue(fits.tryRetainInMemory());
+        assertEquals(small.length, fits.getLength());
+        byte[] big = new byte[2 * 1024 * 1024];
+        ReopenableSource over = new ReopenableSource(() -> new 
ByteArrayInputStream(big), null, -1, "");
+        assertFalse(over.tryRetainInMemory());
     }
 
     @Test
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamOwnershipTest.java 
b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamOwnershipTest.java
index 4953a370c3..c8a84ecbcd 100644
--- 
a/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamOwnershipTest.java
+++ 
b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamOwnershipTest.java
@@ -17,6 +17,7 @@
 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.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -140,21 +141,19 @@ public class TikaInputStreamOwnershipTest {
     }
 
     @Test
-    public void aPinnedChannelOutlivesTmpAndReleasesOnClose() throws Exception 
{
+    public void aPinnedChannelClosesWithTmp() throws Exception {
         CacheMemoryBudget budget = budget();
         TemporaryResources tmp = new TemporaryResources();
         TikaInputStream tis = reopenable(data(BIG), tmp);
         tis.enableRewind(budget);
         SeekableByteChannel channel = tis.getSeekableByteChannel();
-        long reserved = budget.getReservedBytes();
-        assertTrue(reserved > 0);
+        assertTrue(budget.getReservedBytes() > 0);
 
-        tmp.close();
+        tmp.close();   // the scope ends without the channel being closed
 
-        assertEquals(reserved, budget.getReservedBytes(),
-                "a handed-out channel still pins the array; nothing is 
released under it");
-        assertEquals(BIG, channel.size());
+        assertFalse(channel.isOpen(), "the channel is tmp's to close");
+        assertEquals(0, budget.getReservedBytes(), "and its pin is released");
         channel.close();
-        assertEquals(0, budget.getReservedBytes(), "the last pin releases the 
reservation");
+        assertEquals(0, budget.getReservedBytes(), "never released twice");
     }
 }
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamPathsTest.java 
b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamPathsTest.java
new file mode 100644
index 0000000000..5782cc784f
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamPathsTest.java
@@ -0,0 +1,400 @@
+/*
+ * 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 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.SeekableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+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;
+
+/** Happy and unhappy paths through every source: what is released, and what 
bytes come back. */
+public class TikaInputStreamPathsTest {
+
+    private static final int MB = 1024 * 1024;
+
+    @TempDir
+    Path dir;
+
+    private static byte[] data(int size) {
+        byte[] d = new byte[size];
+        for (int i = 0; i < size; i++) {
+            d[i] = (byte) (i * 13 + 5);
+        }
+        return d;
+    }
+
+    /** Counts closes; throws after {@code failAt} bytes when that is 
positive. */
+    private static final class Probe extends InputStream {
+        final ByteArrayInputStream in;
+        final long failAt;
+        long read;
+        int closes;
+
+        Probe(byte[] d, long failAt) {
+            this.in = new ByteArrayInputStream(d);
+            this.failAt = failAt;
+        }
+
+        @Override
+        public int read() throws IOException {
+            if (failAt > 0 && read >= failAt) {
+                throw new IOException("probe failure at " + read);
+            }
+            int b = in.read();
+            if (b != -1) {
+                read++;
+            }
+            return b;
+        }
+
+        @Override
+        public int read(byte[] b, int off, int len) throws IOException {
+            if (failAt > 0 && read >= failAt) {
+                throw new IOException("probe failure at " + read);
+            }
+            int n = in.read(b, off, failAt > 0 ? (int) Math.min(len, failAt - 
read) : len);
+            if (n > 0) {
+                read += n;
+            }
+            return n;
+        }
+
+        @Override
+        public void close() {
+            closes++;
+        }
+    }
+
+    private TemporaryResources tmp() {
+        TemporaryResources tmp = new TemporaryResources();
+        tmp.setTemporaryFileDirectory(dir);
+        return tmp;
+    }
+
+    private long tempFiles() throws IOException {
+        try (Stream<Path> s = Files.list(dir)) {
+            return s.count();
+        }
+    }
+
+    private static byte[] readChannel(SeekableByteChannel ch) throws 
IOException {
+        ByteBuffer buf = ByteBuffer.allocate((int) ch.size());
+        ch.position(0);
+        while (buf.hasRemaining() && ch.read(buf) != -1) {
+            // fill
+        }
+        return buf.array();
+    }
+
+    @Test
+    public void failureMidReadReleasesBudgetFilesAndStream() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(64L * MB);
+        Probe probe = new Probe(data(3 * MB), 2 * MB);
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(probe, tmp, new Metadata());
+        tis.enableRewind(budget);
+        assertThrows(IOException.class, () -> IOUtils.toByteArray(tis));
+        assertTrue(budget.getReservedBytes() > 0, "the cache grew past the 
floor before the failure");
+        tis.close();
+        assertEquals(0, budget.getReservedBytes());
+        assertEquals(0, tempFiles());
+        assertEquals(1, probe.closes, "the wrapped stream is closed once");
+    }
+
+    @Test
+    public void spillIsCleanedUpAndReplaysTheSameBytes() throws Exception {
+        byte[] d = data(3 * MB);
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(new Probe(d, 0), tmp, new 
Metadata());
+        tis.enableRewind(null);
+        tis.mark(1000);
+        assertArrayEquals(d, IOUtils.toByteArray(tis), "first pass");
+        assertTrue(tis.hasFile(), "past the threshold the cache spilled");
+        assertEquals(1, tempFiles());
+        tis.reset();
+        assertEquals(d[0] & 0xff, tis.read(), "reset across the spill lands on 
the mark");
+        tis.rewind();
+        assertArrayEquals(d, IOUtils.toByteArray(tis), "second pass, from the 
spill");
+        assertEquals(d.length, tis.getLength());
+        assertTrue(tis.hasReliableLength());
+        tis.close();
+        tis.close();
+        assertEquals(0, tempFiles(), "spill deleted on close, double close 
harmless");
+    }
+
+    @Test
+    public void channelsFromEverySourceCloseWithTheStream() throws Exception {
+        byte[] d = data(3 * MB);
+        Metadata md = new Metadata();
+        md.set(HttpHeaders.CONTENT_LENGTH, Integer.toString(d.length));
+        // spilled cache
+        TikaInputStream caching = TikaInputStream.get(new Probe(d, 0), tmp(), 
new Metadata());
+        caching.enableRewind(null);
+        SeekableByteChannel c1 = caching.getSeekableByteChannel();
+        assertArrayEquals(d, readChannel(c1));
+        caching.close();
+        assertFalse(c1.isOpen(), "a spill-file channel is the stream's to 
close");
+        // re-openable, spilled (no budget: over the floor it cannot be 
retained)
+        TikaInputStream reopen = TikaInputStream.get(() -> new 
ByteArrayInputStream(d), tmp(), md);
+        SeekableByteChannel c2 = reopen.getSeekableByteChannel();
+        assertArrayEquals(d, readChannel(c2));
+        reopen.close();
+        assertFalse(c2.isOpen(), "a re-openable source's spill channel is the 
stream's to close");
+        // file
+        Path file = dir.resolve("f.bin");
+        Files.write(file, d);
+        TikaInputStream fs = TikaInputStream.get(file);
+        SeekableByteChannel c3 = fs.getSeekableByteChannel();
+        assertArrayEquals(d, readChannel(c3));
+        fs.close();
+        assertFalse(c3.isOpen(), "a file channel is the stream's to close");
+    }
+
+    @Test
+    public void reopenableFailureMidRetentionReleasesTheReservation() throws 
Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(64L * MB);
+        byte[] d = data(3 * MB);
+        Metadata md = new Metadata();
+        md.set(HttpHeaders.CONTENT_LENGTH, Integer.toString(d.length));
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(() -> new Probe(d, 2 * MB), 
tmp, md);
+        tis.enableRewind(budget);
+        assertThrows(IOException.class, tis::tryRetainInMemory);
+        assertEquals(0, budget.getReservedBytes(), "a failed drain returns 
what it reserved");
+        tis.close();
+        assertEquals(0, budget.getReservedBytes());
+        assertEquals(0, tempFiles());
+    }
+
+    @Test
+    public void unknownLengthOverTheFloorOpensTheSourceTwice() throws 
Exception {
+        byte[] d = data(2 * MB);
+        AtomicInteger opens = new AtomicInteger();
+        TikaInputStream tis = TikaInputStream.get(() -> {
+            opens.incrementAndGet();
+            return new ByteArrayInputStream(d);
+        }, tmp(), new Metadata());
+        try (SeekableByteChannel ch = tis.getSeekableByteChannel()) {
+            assertEquals(d.length, ch.size());
+        }
+        // a doomed in-memory attempt up to the floor, then the spool: known 
cost, pinned here
+        assertEquals(2, opens.get());
+        tis.close();
+    }
+
+    @Test
+    public void pinnedChannelSurvivesASpill() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(2L * MB);
+        byte[] d = data(4 * MB);
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(new Probe(d, 0), tmp, new 
Metadata());
+        tis.enableRewind(budget);
+        // the channel drains everything: 4 MB does not fit a 2 MB budget, so 
it spills
+        try (SeekableByteChannel ch = tis.getSeekableByteChannel()) {
+            assertArrayEquals(d, readChannel(ch));
+        }
+        assertEquals(0, budget.getReservedBytes(), "spilled: nothing stays 
reserved");
+        assertArrayEquals(d, IOUtils.toByteArray(tis), "the stream still reads 
the whole source");
+        tis.close();
+        assertEquals(0, tempFiles());
+    }
+
+    @Test
+    public void inMemoryChannelIsClosedWithTheStream() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(64L * MB);
+        byte[] d = data(3 * MB);
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(new Probe(d, 0), tmp, new 
Metadata());
+        tis.enableRewind(budget);
+        SeekableByteChannel ch = tis.getSeekableByteChannel();
+        assertTrue(budget.getReservedBytes() > 0);
+        assertArrayEquals(d, readChannel(ch));
+        tis.close();   // a parser that threw before closing its channel
+        assertFalse(ch.isOpen());
+        assertEquals(0, budget.getReservedBytes(), "the pin goes with the 
stream");
+        ch.close();
+        assertEquals(0, budget.getReservedBytes(), "closing it again releases 
nothing twice");
+    }
+
+    @Test
+    public void failedReopenDoesNotOrphanTheFreshStream() throws Exception {
+        byte[] full = data(64 * 1024);
+        byte[] truncated = data(100);
+        AtomicInteger opens = new AtomicInteger();
+        Probe[] streams = new Probe[2];
+        TikaInputStream tis = TikaInputStream.get(() -> {
+            int n = opens.getAndIncrement();
+            return streams[n] = new Probe(n == 0 ? full : truncated, 0);
+        }, tmp(), new Metadata());
+        tis.readNBytes(new byte[1000], 0, 1000);
+        tis.mark(10);
+        tis.readNBytes(new byte[32 * 1024], 0, 32 * 1024);   // past the 
limit: reset must reopen
+        assertThrows(IOException.class, tis::reset, "the re-opened source is 
too short to skip to the mark");
+        assertEquals(2, opens.get());
+        assertEquals(1, streams[1].closes, "the stream the failed re-open left 
behind is closed");
+        tis.close();
+    }
+
+    @Test
+    public void fileSourceMarkResetAndChannelDoNotMovePosition() throws 
Exception {
+        byte[] d = data(64 * 1024);
+        Path file = dir.resolve("g.bin");
+        Files.write(file, d);
+        try (TikaInputStream tis = TikaInputStream.get(file)) {
+            tis.readNBytes(new byte[100], 0, 100);
+            tis.mark(1000);
+            tis.readNBytes(new byte[400], 0, 400);
+            try (SeekableByteChannel ch = tis.getSeekableByteChannel()) {
+                assertEquals(d.length, ch.size());
+            }
+            assertEquals(500, tis.getPosition());
+            tis.reset();
+            assertEquals(d[100] & 0xff, tis.read());
+            assertEquals(101, tis.getPosition());
+            tis.mark(10);
+            tis.readNBytes(new byte[32 * 1024], 0, 32 * 1024);   // past the 
limit: reopen path
+            tis.reset();
+            assertEquals(d[101] & 0xff, tis.read());
+        }
+    }
+
+    @Test
+    public void passthroughRefusesLateRewindButReadsCorrectly() throws 
Exception {
+        byte[] d = data(64 * 1024);
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(new Probe(d, 0), tmp, new 
Metadata());
+        tis.readNBytes(new byte[10], 0, 10);
+        assertThrows(IOException.class, () -> tis.enableRewind(null), "rewind 
must be enabled at 0");
+        assertThrows(IOException.class, tis::getPath, "no spool from 
mid-stream");
+        tis.mark(100);
+        tis.readNBytes(new byte[50], 0, 50);
+        tis.reset();
+        assertEquals(d[10] & 0xff, tis.read(), "passthrough mark/reset is the 
buffer's");
+        tis.close();
+        assertEquals(0, tempFiles());
+    }
+
+    @Test
+    public void lengthIsMeasuredAtEofWithoutASpill() throws Exception {
+        byte[] d = data(5000);
+        // lying declared length, cache mode
+        Metadata lying = new Metadata();
+        lying.set(HttpHeaders.CONTENT_LENGTH, "4000");
+        TikaInputStream tis = TikaInputStream.get(new Probe(d, 0), tmp(), 
lying);
+        tis.enableRewind(null);
+        assertFalse(tis.hasReliableLength());
+        IOUtils.toByteArray(tis);
+        assertEquals(5000, tis.getLength());
+        assertTrue(tis.hasReliableLength());
+        assertEquals(0, tempFiles(), "the cache knows the size; nothing is 
spooled to measure it");
+        tis.close();
+        // no declared length, passthrough
+        tis = TikaInputStream.get(new Probe(d, 0), tmp(), new Metadata());
+        IOUtils.toByteArray(tis);
+        assertEquals(5000, tis.getLength());
+        assertTrue(tis.hasReliableLength());
+        assertEquals(0, tempFiles());
+        tis.close();
+    }
+
+    @Test
+    public void unknownLengthIsRetainedWhenItFits() throws Exception {
+        byte[] d = data(500 * 1024);
+        AtomicInteger opens = new AtomicInteger();
+        TikaInputStream tis = TikaInputStream.get(() -> {
+            opens.incrementAndGet();
+            return new ByteArrayInputStream(d);
+        }, tmp(), new Metadata());
+        tis.enableRewind(null);
+        assertTrue(tis.tryRetainInMemory(), "under the floor it is held 
without a length");
+        assertArrayEquals(d, IOUtils.toByteArray(tis));
+        tis.rewind();
+        assertArrayEquals(d, IOUtils.toByteArray(tis));
+        assertEquals(1, opens.get(), "digest and parse read the retained 
bytes");
+        assertEquals(d.length, tis.getLength());
+        tis.close();
+    }
+
+    @Test
+    public void skipAndNullTmpFollowTheContract() throws Exception {
+        byte[] d = data(1000);
+        Probe probe = new Probe(d, 0);
+        TikaInputStream tis = TikaInputStream.get(probe, null, new Metadata());
+        assertEquals(0, tis.skip(-1));
+        assertEquals(0, tis.skip(0));
+        assertEquals(10, tis.skip(10));
+        assertEquals(d[10] & 0xff, tis.read());
+        tis.close();
+        assertEquals(1, probe.closes);
+    }
+
+    @Test
+    public void spilledReplayByteAtATimeAndTrimmedReservation() throws 
Exception {
+        byte[] d = data(3 * MB);
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(new Probe(d, 0), tmp, new 
Metadata());
+        tis.enableRewind(null);   // no budget: spills at 1 MB
+        IOUtils.toByteArray(tis);
+        tis.rewind();
+        for (int i = 0; i < 20000; i++) {
+            assertEquals(d[i] & 0xff, tis.read(), "byte " + i + " from the 
spill window");
+        }
+        tis.close();
+
+        CacheMemoryBudget budget = new CacheMemoryBudget(64L * MB);
+        tis = TikaInputStream.get(new Probe(d, 0), tmp(), new Metadata());
+        tis.enableRewind(budget);
+        IOUtils.toByteArray(tis);
+        assertEquals(2L * MB, budget.getReservedBytes(),
+                "after the drain the reservation is the content over the 
floor, not the doubled capacity");
+        tis.close();
+        assertEquals(0, budget.getReservedBytes());
+    }
+
+    @Test
+    public void tmpClosedFirstThenStream() throws Exception {
+        CacheMemoryBudget budget = new CacheMemoryBudget(64L * MB);
+        byte[] d = data(3 * MB);
+        TemporaryResources tmp = tmp();
+        TikaInputStream tis = TikaInputStream.get(new Probe(d, 0), tmp, new 
Metadata());
+        tis.enableRewind(budget);
+        IOUtils.toByteArray(tis);
+        tmp.close();
+        assertEquals(0, budget.getReservedBytes());
+        assertEquals(0, tempFiles());
+        tis.close();
+        assertEquals(0, budget.getReservedBytes(), "never negative");
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/ParseContextTest.java 
b/tika-core/src/test/java/org/apache/tika/parser/ParseContextTest.java
index 4e709a1cf8..ee8a361653 100644
--- a/tika-core/src/test/java/org/apache/tika/parser/ParseContextTest.java
+++ b/tika-core/src/test/java/org/apache/tika/parser/ParseContextTest.java
@@ -17,13 +17,29 @@
 package org.apache.tika.parser;
 
 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.assertTrue;
 
 import org.junit.jupiter.api.Test;
 
 public class ParseContextTest {
 
+    @Test
+    public void testJsonConfigTrustSurvivesCopy() {
+        ParseContext operator = new ParseContext();
+        operator.setJsonConfig("x", "{}", true);
+        ParseContext request = new ParseContext();
+        request.setJsonConfig("y", "{}");
+        ParseContext merged = new ParseContext();
+        merged.copyFrom(operator);
+        merged.copyFrom(request);
+        assertTrue(merged.getJsonConfig("x").trusted());
+        assertFalse(merged.getJsonConfig("y").trusted());
+    }
+
+
     @Test
     public void testCopyFromInvalidatesStaleResolvedConfigs() {
         // Simulate a "default" context that has already resolved a config
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
 
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
index 139c6ff7e3..894a42163e 100644
--- 
a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
@@ -569,6 +569,42 @@ public class InferenceDispatcherTest {
         assertEquals(InputKind.MEDIA, InputKind.of(MediaType.audio("mpeg")));
     }
 
+    @Test
+    public void testMediaMaxChunksIsNotAUnitCap() throws Exception {
+        RecordingTask task = new RecordingTask();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(InferenceBinding.builder("v", 
"engine",
+                        
InputKind.MEDIA).modality(Modality.AUDIO).maxChunks(1).build(),
+                        new RecordingEngine(), List.of(task))));
+        ParseContext context = new ParseContext();
+        MediaType mp3 = MediaType.audio("mpeg");
+        for (int i = 0; i < 3; i++) {
+            dispatcher.offer(mp3, new Metadata(), new Metadata(), file("clip " 
+ i), context);
+        }
+        dispatcher.flush(new Metadata(), context);
+        assertEquals(3, task.runs.get(0).size(), "maxChunks caps segments per 
file, not files");
+    }
+
+    @Test
+    public void testMediaUnitsAreBoundedPerTree() throws Exception {
+        RecordingTask task = new RecordingTask();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(InferenceBinding.builder("v", 
"engine",
+                        InputKind.MEDIA).modality(Modality.AUDIO).build(),
+                        new RecordingEngine(), List.of(task))));
+        ParseContext context = new ParseContext();
+        MediaType mp3 = MediaType.audio("mpeg");
+        Path clip = file("clip");
+        for (int i = 0; i < InferenceDispatcher.MAX_MEDIA_UNITS + 5; i++) {
+            dispatcher.offer(mp3, new Metadata(), new Metadata(), clip, 
context);
+        }
+        Metadata root = new Metadata();
+        dispatcher.flush(root, context);
+        assertEquals(InferenceDispatcher.MAX_MEDIA_UNITS, 
task.runs.get(0).size());
+        assertTrue(String.join(" ", 
root.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .contains("media files"), "the drop is reported on the root");
+    }
+
     @Test
     public void testMediaConfigValidation() throws Exception {
         MediaConfig config = new MediaConfig();
diff --git 
a/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
 
b/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
index f15da1652d..8688a72241 100644
--- 
a/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
+++ 
b/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
@@ -127,6 +127,10 @@ public class TikaHttpClient implements Closeable {
         return new TikaHttpClient(client, executor, connectTimeoutSeconds, 
maxRetries);
     }
 
+    public int getMaxRetries() {
+        return maxRetries;
+    }
+
     /**
      * POST a JSON body to {@code url} and return the response body as a 
string.
      * <p>
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
index b8517355a9..fb29d4374e 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
@@ -294,7 +294,7 @@ public class Tess4JConfig implements Serializable {
         final String[] langs = language.split("\\+");
         for (String lang : langs) {
             if (!lang.matches(
-                    
"([a-zA-Z]{3}(_[a-zA-Z]{3,4}){0,2})|script(/|\\\\)[A-Z][a-zA-Z_]+")) {
+                    
"([a-zA-Z]{3}(_[a-zA-Z]{3,4}){0,2})|(script(/|\\\\))?[A-Z][a-zA-Z_]+")) {
                 invalidLangs.add(lang + " (invalid syntax)");
             } else {
                 validLangs.add(lang);
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
index 44c82e6882..3a4f31d455 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
@@ -55,6 +55,17 @@ public class Tess4JConfigTest {
         assertThrows(IllegalArgumentException.class, () -> 
config.setLanguage("xy"));
     }
 
+    @Test
+    public void testScriptModelsByBareName() {
+        Tess4JConfig config = new Tess4JConfig();
+        for (String ok : new String[]{"Latin", "eng+Latin", "script/Latin", 
"Japanese_vert"}) {
+            config.setLanguage(ok);
+            assertEquals(ok, config.getLanguage());
+        }
+        assertThrows(IllegalArgumentException.class, () -> 
config.setLanguage("latin1"));
+        assertThrows(IllegalArgumentException.class, () -> 
config.setLanguage("script/"));
+    }
+
     @Test
     public void testSetLanguageLeadingPlus() {
         Tess4JConfig config = new Tess4JConfig();
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
index 95690f4e02..c9e4fd4ad2 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
@@ -95,7 +95,14 @@ public abstract class AbstractVLMParser implements Parser, 
Initializable, Closea
 
     protected AbstractVLMParser(VLMOCRConfig config) {
         this.defaultConfig = config;
-        this.httpClient = buildHttpClient();
+    }
+
+    /** Built on first use, so the setters that feed it have run. */
+    synchronized TikaHttpClient httpClient() {
+        if (httpClient == null) {
+            httpClient = buildHttpClient();
+        }
+        return httpClient;
     }
 
     // ---- abstract contract for subclasses ---------------------------------
@@ -220,7 +227,7 @@ public abstract class AbstractVLMParser implements Parser, 
Initializable, Closea
 
         String responseText;
         try {
-            String responseBody = httpClient.postJson(
+            String responseBody = httpClient().postJson(
                     call.url(), call.json(), call.headers(), 
config.getTimeoutMillis(), parseContext);
             responseText = extractResponseText(responseBody, metadata);
             ParseTimeout.checkpoint(parseContext);
@@ -244,9 +251,6 @@ public abstract class AbstractVLMParser implements Parser, 
Initializable, Closea
 
     @Override
     public void initialize() throws TikaConfigException {
-        if (httpClient == null) {
-            httpClient = buildHttpClient();
-        }
         String healthUrl = getHealthCheckUrl(defaultConfig);
         if (healthUrl == null) {
             // No health check configured (e.g. Claude) — assume available
@@ -257,7 +261,7 @@ public abstract class AbstractVLMParser implements Parser, 
Initializable, Closea
             Map<String, String> healthHeaders = defaultConfig.getApiKey() != 
null
                     ? Map.of("Authorization", "Bearer " + 
defaultConfig.getApiKey())
                     : Map.of();
-            httpClient.get(healthUrl, healthHeaders, 
defaultConfig.getTimeoutMillis());
+            httpClient().get(healthUrl, healthHeaders, 
defaultConfig.getTimeoutMillis());
             serverAvailable = true;
             LOG.info("VLM server is available at {}", 
defaultConfig.getBaseUrl());
         } catch (TikaException e) {
@@ -430,8 +434,12 @@ public abstract class AbstractVLMParser implements Parser, 
Initializable, Closea
     }
 
     /** Retries of a 429/502/503/504 answer from the service; 0 fails at once. 
*/
-    public void setMaxRetries(int maxRetries) {
+    public synchronized void setMaxRetries(int maxRetries) {
         defaultConfig.setMaxRetries(maxRetries);
+        if (httpClient != null) {
+            httpClient.close();
+            httpClient = null;
+        }
     }
 
     public String getApiKey() {
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
index fe306231f9..9c1073ce65 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
@@ -243,8 +243,13 @@ class MarkdownToXHTMLEmitter {
         @Override
         public void visit(HtmlBlock htmlBlock) {
             Element body = 
Jsoup.parseBodyFragment(htmlBlock.getLiteral()).body();
+            org.jsoup.nodes.Node previous = null;
             for (org.jsoup.nodes.Node child : body.childNodes()) {
+                if (previous instanceof TextNode && child instanceof TextNode) 
{
+                    characters(" ");   // orphaned cells: jsoup dropped the 
tags between them
+                }
                 emitHtml(child, false);
+                previous = child;
             }
         }
 
@@ -274,6 +279,8 @@ class MarkdownToXHTMLEmitter {
             }
             if (emit) {
                 endElement(tag);
+            } else if (((Element) node).isBlock()) {
+                characters(" ");   // a dropped block still separates its text 
from the next
             }
         }
 
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
index afc03b2e71..d5d421238f 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
@@ -40,6 +40,16 @@ public class MarkdownToXHTMLEmitterTest {
         assertContains("<h3>Heading 3</h3>", xml);
     }
 
+    @Test
+    void testDroppedElementsStillSeparateText() throws Exception {
+        String split = 
"<table>\n<tr><td>North</td><td>120</td></tr>\n\n<tr><td>South</td><td>99</td></tr>\n</table>";
+        String xml = emit(split);
+        assertContains("<td>North</td>", xml);
+        assertContains("South 99", xml);
+        assertContains("k v", emit("<dl><dt>k</dt><dd>v</dd></dl>"));
+        assertContains("a b", emit("<div>a</div><div>b</div>"));
+    }
+
     @Test
     void testParagraph() throws Exception {
         String md = "Hello world.\n\nSecond paragraph.";
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
index b4ee3f6637..6d1f28da99 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
@@ -42,6 +42,15 @@ import org.apache.tika.sax.BodyContentHandler;
 
 public class OpenAIVLMParserTest {
 
+    @Test
+    public void testSetMaxRetriesReachesTheClient() throws Exception {
+        OpenAIVLMParser parser = new OpenAIVLMParser();
+        assertEquals(4, parser.httpClient().getMaxRetries());
+        parser.setMaxRetries(0);
+        assertEquals(0, parser.httpClient().getMaxRetries());
+    }
+
+
     private static final ObjectMapper MAPPER = new ObjectMapper();
 
     private TikaTestHttpServer server;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/detect/ogg/OggDetector.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/detect/ogg/OggDetector.java
index ab92888b4c..434cd3d0f7 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/detect/ogg/OggDetector.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/detect/ogg/OggDetector.java
@@ -34,6 +34,7 @@ import org.gagravarr.skeleton.SkeletonPacketFactory;
 
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.detect.Detector;
+import org.apache.tika.io.CacheMemoryBudget;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.mime.MediaType;
@@ -76,7 +77,10 @@ public class OggDetector implements Detector {
 
         // We could potentially need to go a long way through the
         // file in order to figure out what it is
-        tis.mark((int)tis.getLength() + 1);
+        // rewind-enabled, the mark is a position in a cache that spills past 
its budget,
+        // not a buffer sized by a limit; the limit itself is never taken from 
the length
+        tis.enableRewind(parseContext == null ? null : 
parseContext.get(CacheMemoryBudget.class));
+        tis.mark(Integer.MAX_VALUE);
 
         try {
             // Open the Ogg file - underlying stream stays open as detecting 
only
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
index 733648ddb7..2fe3cf9402 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
@@ -39,6 +39,7 @@ import org.xml.sax.SAXException;
 
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Property;
@@ -170,8 +171,8 @@ public class ImageParser extends AbstractImageParser {
             if (iterator.hasNext()) {
                 ImageReader reader = iterator.next();
                 try {
-                    try (ImageInputStream imageStream = ImageIO
-                            
.createImageInputStream(CloseShieldInputStream.wrap(stream))) {
+                    // a file we already have beats ImageIO copying the stream 
to its own cache file
+                    try (ImageInputStream imageStream = imageInput(stream)) {
                         reader.setInput(imageStream);
                         try {
                             int numImages = reader.getNumImages(true);
@@ -209,6 +210,13 @@ public class ImageParser extends AbstractImageParser {
         }
     }
 
+    private static ImageInputStream imageInput(InputStream stream) throws 
IOException {
+        if (stream instanceof TikaInputStream tis && tis.hasFile()) {
+            return ImageIO.createImageInputStream(tis.getPath().toFile());
+        }
+        return 
ImageIO.createImageInputStream(CloseShieldInputStream.wrap(stream));
+    }
+
     @Override
     MediaType normalizeMediaType(MediaType mediaType) {
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
index 825e2cc2f0..8bacdfec00 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
@@ -168,7 +168,8 @@ public class RawTiffParser extends TiffParser {
     @Override
     protected ImageMetadataConfig getImageMetadataConfig(ParseContext context)
             throws TikaException, IOException {
-        return getConfig(context);
+        ImageMetadataConfig shared = context.get(ImageMetadataConfig.class);
+        return shared != null ? shared : getConfig(context);
     }
 
     private void extractPreviews(TikaInputStream tis, XHTMLContentHandler 
xhtml, Metadata metadata,
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/RawTiffParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/RawTiffParserTest.java
index deb7b0f17c..a658d8622e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/RawTiffParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/RawTiffParserTest.java
@@ -17,6 +17,7 @@
 package org.apache.tika.parser.image;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -38,6 +39,17 @@ import org.apache.tika.parser.Parser;
 
 public class RawTiffParserTest extends TikaTest {
 
+    @Test
+    public void testSharedImageMetadataConfigApplies() throws Exception {
+        ImageMetadataConfig shared = new ImageMetadataConfig();
+        shared.setIncludeIccCurvesAndLuts(true);
+        ParseContext context = new ParseContext();
+        context.set(ImageMetadataConfig.class, shared);
+        assertTrue(new 
RawTiffParser().getImageMetadataConfig(context).isIncludeIccCurvesAndLuts());
+        assertFalse(new RawTiffParser().getImageMetadataConfig(new 
ParseContext())
+                .isIncludeIccCurvesAndLuts());
+    }
+
     private List<Metadata> parseByName(String fileName) throws Exception {
         Metadata metadata = new Metadata();
         metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/extractor/microsoft/MSEmbeddedStreamTranslator.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/extractor/microsoft/MSEmbeddedStreamTranslator.java
index fd4c47a99f..826a6d9bb4 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/extractor/microsoft/MSEmbeddedStreamTranslator.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/extractor/microsoft/MSEmbeddedStreamTranslator.java
@@ -16,13 +16,13 @@
  */
 package org.apache.tika.extractor.microsoft;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.io.output.CloseShieldOutputStream;
-import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream;
 import org.apache.poi.poifs.filesystem.DirectoryEntry;
 import org.apache.poi.poifs.filesystem.DocumentEntry;
 import org.apache.poi.poifs.filesystem.DocumentInputStream;
@@ -58,13 +58,11 @@ public class MSEmbeddedStreamTranslator implements 
EmbeddedStreamTranslator {
     public void translate(TikaInputStream tis, Metadata metadata, OutputStream 
os) throws IOException {
         String contentType = 
metadata.get(org.apache.tika.metadata.HttpHeaders.CONTENT_TYPE);
         if 
("application/vnd.openxmlformats-officedocument.oleObject".equals(contentType)) 
{
-            UnsynchronizedByteArrayOutputStream bos = 
UnsynchronizedByteArrayOutputStream.builder().get();
-            IOUtils.copy(tis, bos);
-            POIFSFileSystem poifs = new POIFSFileSystem(bos.toInputStream());
+            byte[] data = IOUtils.toByteArray(tis);
+            POIFSFileSystem poifs = new POIFSFileSystem(new 
ByteArrayInputStream(data));
             OfficeParser.POIFSDocumentType type = 
OfficeParser.POIFSDocumentType.detectType(poifs);
             String name = metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
 
-            byte[] data = bos.toByteArray();
             if (type == OfficeParser.POIFSDocumentType.OLE10_NATIVE) {
                 try {
                     Ole10Native ole = 
Ole10Native.createFromEmbeddedOleObject(poifs);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLExtractor.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLExtractor.java
index 96acd30d40..11d611a672 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLExtractor.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLExtractor.java
@@ -225,7 +225,7 @@ public abstract class AbstractOOXMLExtractor implements 
OOXMLExtractor {
 
                 if (embeddedExtractor.shouldParseEmbedded(thumbnailMetadata, 
context)) {
                     try (TikaInputStream tis = 
TikaInputStream.get(tPart::getInputStream,
-                            new TemporaryResources(), null)) {
+                            new TemporaryResources(), sized(tPart))) {
                         embeddedExtractor.parseEmbedded(tis,
                                 new EmbeddedContentHandler(handler), 
thumbnailMetadata, context, false);
                     }
@@ -510,7 +510,7 @@ public abstract class AbstractOOXMLExtractor implements 
OOXMLExtractor {
             //the part is in the package already: re-open it on rewind instead 
of
             //caching a copy that a digest of a large part would spill to disk
             try (TikaInputStream tis = 
TikaInputStream.get(part::getInputStream,
-                    new TemporaryResources(), null)) {
+                    new TemporaryResources(), sized(part))) {
                 embeddedExtractor
                         .parseEmbedded(tis, xhtml, metadata, context, true);
             }
@@ -730,4 +730,14 @@ public abstract class AbstractOOXMLExtractor implements 
OOXMLExtractor {
 
     }
 
+
+    /** The part's size, so the stream can be retained and its length is known 
without a read. */
+    private static Metadata sized(PackagePart part) {
+        Metadata m = new Metadata();
+        long size = part.getSize();
+        if (size >= 0) {
+            m.set(HttpHeaders.CONTENT_LENGTH, Long.toString(size));
+        }
+        return m;
+    }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
index 35cc780bb7..1a8bee22e9 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
@@ -304,23 +304,20 @@ public class TesseractOCRParser extends 
AbstractExternalProcessParser
         }
     }
 
-    private TesseractOCRConfig getConfig(ParseContext parseContext) throws 
TikaConfigException, IOException {
-        // Check for JSON config with component-specific runtime config
+    TesseractOCRConfig getConfig(ParseContext parseContext) throws 
TikaConfigException, IOException {
         if (parseContext.hasJsonConfig("tesseract-ocr-parser")) {
-            // First validate that no paths are being set by deserializing 
into RuntimeConfig
-            // The RuntimeConfig setters will throw TikaConfigException if 
JSON contains path fields
-            TesseractOCRConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
-                    parseContext,
-                    "tesseract-ocr-parser",
-                    TesseractOCRConfig.RuntimeConfig.class,
-                    new TesseractOCRConfig.RuntimeConfig());
-
-            // Short-circuit if skipOcr is set
-            if (runtimeConfig.isSkipOcr()) {
-                return runtimeConfig;
+            // per-request JSON is screened by RuntimeConfig (no paths, no 
otherTesseractConfig);
+            // operator JSON (config file, presets) is not
+            if (!parseContext.getJsonConfig("tesseract-ocr-parser").trusted()) 
{
+                TesseractOCRConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
+                        parseContext,
+                        "tesseract-ocr-parser",
+                        TesseractOCRConfig.RuntimeConfig.class,
+                        new TesseractOCRConfig.RuntimeConfig());
+                if (runtimeConfig.isSkipOcr()) {
+                    return runtimeConfig;
+                }
             }
-
-            // Now merge with defaultConfig using the base class
             return ParseContextConfig.getConfig(
                     parseContext,
                     "tesseract-ocr-parser",
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTrustTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTrustTest.java
new file mode 100644
index 0000000000..04eab0e81a
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTrustTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.ocr;
+
+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 org.junit.jupiter.api.Test;
+
+import org.apache.tika.parser.ParseContext;
+
+/** otherTesseractConfig is accepted from operator JSON (config file, 
presets), refused per request. */
+public class TesseractOCRConfigTrustTest {
+
+    private static final String JSON =
+            "{\"otherTesseractConfig\": {\"tessedit_char_whitelist\": 
\"0123456789\"}}";
+
+    @Test
+    public void operatorJsonIsAccepted() throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("tesseract-ocr-parser", JSON, true);
+        assertEquals("0123456789", new TesseractOCRParser().getConfig(context)
+                .getOtherTesseractConfig().get("tessedit_char_whitelist"));
+    }
+
+    @Test
+    public void requestJsonIsRefused() {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("tesseract-ocr-parser", JSON);
+        Exception e = assertThrows(Exception.class, () -> new 
TesseractOCRParser().getConfig(context));
+        assertTrue(String.valueOf(e).contains("otherTesseractConfig")
+                || 
String.valueOf(e.getCause()).contains("otherTesseractConfig"), 
String.valueOf(e));
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
index 7f1018c8a2..cb73cdc034 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
@@ -631,8 +631,12 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         }
         //the file is in the document already: re-open (re-decode) it on rewind
         //rather than cache a copy that a digest of a large attachment would 
spill
+        Metadata sized = new Metadata();
+        if (pdEmbeddedFile.getSize() >= 0) {
+            sized.set(HttpHeaders.CONTENT_LENGTH, 
Long.toString(pdEmbeddedFile.getSize()));
+        }
         TikaInputStream tis = 
TikaInputStream.get(pdEmbeddedFile::createInputStream,
-                new TemporaryResources(), null);
+                new TemporaryResources(), sized);
 
         setOrReplaceAttribute("class", "embedded", attributes);
         setOrReplaceAttribute("id", fileName, attributes);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFPagesEmitTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFPagesEmitTest.java
index e89686e55b..d02ceb5001 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFPagesEmitTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFPagesEmitTest.java
@@ -368,6 +368,47 @@ public class PDFPagesEmitTest extends TikaTest {
                     () -> parser.parse(tis, new ToXMLContentHandler(), 
metadata, context));
         }
         assertEquals(2, calls[0], "once per page: OCR asked, emission did not 
ask again");
+        assertEquals(2, 
metadata.getValues(Rendering.RENDER_FAILED_PAGE).length,
+                "one failure per page, none re-recorded after the parse 
threw");
+    }
+
+    /** A page whose render already failed is not rendered again when the 
writer then dies on it. */
+    @Test
+    public void testFailedPageIsNotRetriedWhenTheWriterDies() throws Exception 
{
+        PDFParserConfig config = emitting();
+        config.pages().setText(TextPolicy.EXTRACT_AND_OCR);
+        int[] calls = new int[1];
+        PDFParser parser = new PDFParser();
+        parser.setRenderer(new PDFBoxRenderer() {
+            @Override
+            public RenderResults render(TikaInputStream tis, Metadata metadata,
+                                        ParseContext parseContext, 
RenderRequest... requests)
+                    throws IOException {
+                calls[0]++;
+                throw new IOException("engine down");
+            }
+        });
+        parser.setContentEnrichers(new CompositeContentEnricher(List.of(new 
ImageSink())));
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, new AutoDetectParser(new ImageSink()));
+        Metadata metadata = new Metadata();
+        ContentHandler dying = new ToXMLContentHandler() {
+            int pageDivs = 0;
+            @Override
+            public void endElement(String uri, String localName, String qName) 
throws SAXException {
+                super.endElement(uri, localName, qName);
+                if ("div".equals(localName) && ++pageDivs == 2) {
+                    throw new SAXException("writer died on page 2");
+                }
+            }
+        };
+        try (TikaInputStream tis = TikaInputStream.get(
+                getResourceAsStream("/test-documents/" + TWO_PAGES))) {
+            assertThrows(Exception.class, () -> parser.parse(tis, dying, 
metadata, context));
+        }
+        assertEquals(2, calls[0], "page 2's failed render is not attempted 
again");
+        assertEquals(2, 
metadata.getValues(Rendering.RENDER_FAILED_PAGE).length);
     }
 
     /**
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 b0c97c1973..a57c8fbca4 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
@@ -113,42 +113,27 @@ public class SevenZParser extends AbstractArchiveParser {
             password = provider.getPassword(metadata);
         }
 
-        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()
-                    .setSeekableByteChannel(channel)
-                    .setMaxMemoryLimitKiB(defaultConfig.getMemoryLimitInKb());
-            if (password == null) {
-                sevenZFile = builder.get();
-            } else {
-                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());
 
         EmbeddedDocumentExtractor extractor =
                 EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
 
         XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, 
context);
-        xhtml.startDocument();
 
+        // SevenZFile.close() closes the channel it was built on; until it 
exists the channel
+        // is ours to close, and the SAX document is not started until the 
archive opened
+        SeekableByteChannel channel = tis.getSeekableByteChannel();
+        SevenZFile sevenZFile = null;
+        boolean started = false;
         try {
+            // Use setMaxMemoryLimitKiB (direct KiB); setMaxMemoryLimitKb 
divides the arg by 1024.
+            SevenZFile.Builder builder = new SevenZFile.Builder()
+                    .setSeekableByteChannel(channel)
+                    .setMaxMemoryLimitKiB(defaultConfig.getMemoryLimitInKb());
+            sevenZFile = password == null ? builder.get()
+                    : builder.setPassword(password.toCharArray()).get();
+            xhtml.startDocument();
+            started = true;
             SevenZArchiveEntry entry = sevenZFile.getNextEntry();
             while (entry != null) {
                 if (!entry.isDirectory()) {
@@ -159,10 +144,17 @@ public class SevenZParser extends AbstractArchiveParser {
         } catch (PasswordRequiredException e) {
             throw new EncryptedDocumentException(e);
         } catch (MemoryLimitException e) {
+            // at open time (assertValidity) or lazily on the first 
getNextEntry()
             throw new TikaMemoryLimitException(e.getMessage());
         } finally {
-            sevenZFile.close();
-            xhtml.endDocument();
+            if (sevenZFile != null) {
+                sevenZFile.close();
+            } else {
+                channel.close();
+            }
+            if (started) {
+                xhtml.endDocument();
+            }
         }
     }
 
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 eb3ed69419..d0e012ba11 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
@@ -52,6 +52,7 @@ import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.CacheMemoryBudget;
 import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
@@ -242,7 +243,7 @@ public class ZipParser extends AbstractArchiveParser {
             parseWithZipFile(zipFile, tis, handler, metadata, context, config);
         } else {
             // Use streaming - enable rewind for DATA_DESCRIPTOR retry
-            tis.enableRewind();
+            tis.enableRewind(context.get(CacheMemoryBudget.class));
             String dataDescriptorRequired = 
metadata.get(Zip.DETECTOR_DATA_DESCRIPTOR_REQUIRED);
             parseWithStream(tis, handler, metadata, context, config,
                     "true".equals(dataDescriptorRequired));
@@ -294,7 +295,7 @@ public class ZipParser extends AbstractArchiveParser {
 
         // Perform integrity check if enabled
         if (config.isIntegrityCheck()) {
-            tis.enableRewind();
+            tis.enableRewind(context.get(CacheMemoryBudget.class));
             tis.rewind();
             performIntegrityCheck(tis, metadata, centralDirectoryEntries, 
config);
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/PackageEmbeddedBudgetTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/PackageEmbeddedBudgetTest.java
index e73e9b1fd4..2c82171155 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/PackageEmbeddedBudgetTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/PackageEmbeddedBudgetTest.java
@@ -19,15 +19,22 @@ package org.apache.tika.parser.pkg;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
+import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Base64;
 import java.util.List;
 
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.apache.commons.compress.archivers.ar.ArArchiveEntry;
+import org.apache.commons.compress.archivers.ar.ArArchiveOutputStream;
+import org.apache.commons.compress.archivers.cpio.CpioArchiveEntry;
+import org.apache.commons.compress.archivers.cpio.CpioArchiveOutputStream;
 import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
 import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
-import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import org.apache.tika.digest.DigesterFactory;
 import org.apache.tika.digest.InputStreamDigester;
@@ -47,41 +54,64 @@ public class PackageEmbeddedBudgetTest extends 
AbstractPkgTest {
     @TempDir
     Path tmp;
 
+    private static byte[] entry(int i) {
+        byte[] data = new byte[2 * 1024 * 1024];
+        for (int j = 0; j < data.length; j++) {
+            data[j] = (byte) ('a' + (j + i) % 26);
+        }
+        return data;
+    }
+
     /** Three entries over the 1 MB StreamCache threshold, so each one 
reserves budget. */
-    private Path buildTar() throws Exception {
-        Path tar = tmp.resolve("entries.tar");
-        try (TarArchiveOutputStream out =
-                     new TarArchiveOutputStream(Files.newOutputStream(tar))) {
+    private Path build(String ext) throws Exception {
+        Path archive = tmp.resolve("entries." + ext);
+        try (OutputStream os = Files.newOutputStream(archive);
+             ArchiveOutputStream<?> out = switch (ext) {
+                 case "tar" -> new TarArchiveOutputStream(os);
+                 case "cpio" -> new CpioArchiveOutputStream(os);
+                 case "ar" -> new ArArchiveOutputStream(os);
+                 default -> throw new IllegalArgumentException(ext);
+             }) {
             for (int i = 0; i < 3; i++) {
-                byte[] data = new byte[2 * 1024 * 1024];
-                for (int j = 0; j < data.length; j++) {
-                    data[j] = (byte) ('a' + (j + i) % 26);
+                byte[] data = entry(i);
+                String name = "entry-" + i + ".txt";
+                switch (ext) {
+                    case "tar" -> {
+                        TarArchiveEntry e = new TarArchiveEntry(name);
+                        e.setSize(data.length);
+                        ((TarArchiveOutputStream) out).putArchiveEntry(e);
+                    }
+                    case "cpio" -> {
+                        CpioArchiveEntry e = new CpioArchiveEntry(name);
+                        e.setSize(data.length);
+                        ((CpioArchiveOutputStream) out).putArchiveEntry(e);
+                    }
+                    default -> ((ArArchiveOutputStream) out)
+                            .putArchiveEntry(new ArArchiveEntry(name, 
data.length));
                 }
-                TarArchiveEntry entry = new TarArchiveEntry("entry-" + i + 
".txt");
-                entry.setSize(data.length);
-                out.putArchiveEntry(entry);
                 out.write(data);
                 out.closeArchiveEntry();
             }
         }
-        return tar;
+        return archive;
     }
 
-    @Test
-    public void budgetIsReleasedAfterEachEntry() throws Exception {
+    @ParameterizedTest
+    @ValueSource(strings = {"tar", "cpio", "ar"})
+    public void budgetIsReleasedAfterEachEntry(String ext) throws Exception {
         CacheMemoryBudget budget = new CacheMemoryBudget(64L * 1024 * 1024);
         ParseContext context = new ParseContext();
         context.set(DigesterFactory.class, () -> new 
InputStreamDigester("MD5", "tk:digest:MD5",
                 bytes -> Base64.getEncoder().encodeToString(bytes)));
         context.set(CacheMemoryBudget.class, budget);
 
-        List<Metadata> metadataList = getRecursiveMetadata(buildTar(), 
context, false);
+        List<Metadata> metadataList = getRecursiveMetadata(build(ext), 
context, false);
 
-        assertEquals(4, metadataList.size(), "container plus three entries");
+        assertEquals(4, metadataList.size(), ext + ": container plus three 
entries");
         for (int i = 1; i < metadataList.size(); i++) {
-            assertNotNull(metadataList.get(i).get("tk:digest:MD5"), "entry " + 
i + " digested");
+            assertNotNull(metadataList.get(i).get("tk:digest:MD5"), ext + " 
entry " + i + " digested");
         }
         assertEquals(0, budget.getReservedBytes(),
-                "every entry's in-memory cache must return its reservation on 
close");
+                ext + ": every entry's in-memory cache must return its 
reservation on close");
     }
 }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
 
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
index 9b79b412ce..2ac419eb95 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
@@ -116,7 +116,7 @@ public class ParseContextDeserializer extends 
JsonDeserializer<ParseContext> {
             JsonNode value = contextNode.get(name);
             checkForDuplicateContextKey(name, seenContextKeys);
             // Store as a lazy JSON config (plain mapper: the main one may be 
binary/Smile).
-            parseContext.setJsonConfig(name, 
plainMapper().writeValueAsString(value));
+            parseContext.setJsonConfig(name, 
plainMapper().writeValueAsString(value), !restricted);
         }
 
         return parseContext;
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/serialization/serdes/ParseContextTrustTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/serialization/serdes/ParseContextTrustTest.java
new file mode 100644
index 0000000000..582b167516
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/serialization/serdes/ParseContextTrustTest.java
@@ -0,0 +1,36 @@
+/*
+ * 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.serialization.serdes;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+/** Operator JSON (restricted=false) is trusted; per-request JSON 
(restricted=true) is not. */
+public class ParseContextTrustTest {
+
+    @Test
+    public void testTrustFollowsRestriction() throws Exception {
+        var node = new ObjectMapper().readTree("{\"tesseract-ocr-parser\": 
{\"skipOcr\": true}}");
+        assertTrue(ParseContextDeserializer.readParseContext(node, false)
+                .getJsonConfig("tesseract-ocr-parser").trusted());
+        assertFalse(ParseContextDeserializer.readParseContext(node, true)
+                .getJsonConfig("tesseract-ocr-parser").trusted());
+    }
+}
diff --git a/tika-server/docker-build/docker-tool.sh 
b/tika-server/docker-build/docker-tool.sh
index ac5d03fef0..8fd789b68e 100755
--- a/tika-server/docker-build/docker-tool.sh
+++ b/tika-server/docker-build/docker-tool.sh
@@ -148,7 +148,8 @@ test_docker_image_uat() {
 # Whether the image's HTTP client retries a 429 (TIKA-4912): the images carry 
no unzip, so the
 # jar is copied out of a stopped container and inspected on the host.
 image_client_retries() {
-  local probe="$1-probe" tmp rc=1
+  local probe tmp rc=1
+  probe="$(printf "%s" "$1" | tr "/:" "__")-probe"   # a container name cannot 
hold / or :
   tmp="$(mktemp -d /tmp/tika-uat-probe.XXXXXX)"
   docker create --name "$probe" "$1" >/dev/null 2>&1 || { rm -rf "$tmp"; 
return 1; }
   if docker cp "$probe:/opt/tika-server/lib/." "$tmp/" >/dev/null 2>&1; then
diff --git a/tika-server/docker-build/sample-configs/vision/vlm-claude.json 
b/tika-server/docker-build/sample-configs/vision/vlm-claude.json
index c56f812982..ad27c5a509 100644
--- a/tika-server/docker-build/sample-configs/vision/vlm-claude.json
+++ b/tika-server/docker-build/sample-configs/vision/vlm-claude.json
@@ -9,7 +9,7 @@
   "engines": {
     "claude": {
       "claude-vlm-parser": {
-        "apiKey": "${ANTHROPIC_API_KEY}",
+        "apiKey": "${env:ANTHROPIC_API_KEY}",
         "model": "claude-sonnet-4-20250514"
       }
     }
diff --git a/tika-server/docker-build/sample-configs/vision/vlm-gemini.json 
b/tika-server/docker-build/sample-configs/vision/vlm-gemini.json
index e8f80b845a..f6a0e0ea9c 100644
--- a/tika-server/docker-build/sample-configs/vision/vlm-gemini.json
+++ b/tika-server/docker-build/sample-configs/vision/vlm-gemini.json
@@ -8,7 +8,7 @@
   "engines": {
     "gemini": {
       "gemini-vlm-parser": {
-        "apiKey": "${GEMINI_API_KEY}",
+        "apiKey": "${env:GEMINI_API_KEY}",
         "model": "gemini-2.5-flash"
       }
     }
diff --git 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
index 8820c6bccd..64f62c7c47 100644
--- 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
+++ 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
@@ -503,7 +503,8 @@ public class TikaResourceTest extends CXFTestBase {
                     .create(endPoint + TIKA_PATH + "/config")
                     .type("multipart/form-data")
                     .post(new MultipartBody(Arrays.asList(fileAtt, 
configAtt)));
-            assertEquals(500, response.getStatus());
+            // a raw NUL in the JSON is rejected before any parse: bad 
request, not a parse failure
+            assertEquals(400, response.getStatus());
         } catch (ProcessingException e) {
             //can't tell why this intermittently happens. :(
             //started after the upgrade to 3.2.7

Reply via email to