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 87c1816bc0 TIKA-4730 - improve uat (#2914)
87c1816bc0 is described below
commit 87c1816bc0d5831b19f4510ebabc3e1ed905d70a
Author: Tim Allison <[email protected]>
AuthorDate: Mon Jun 29 10:28:43 2026 -0400
TIKA-4730 - improve uat (#2914)
---
.../integration-testing/run-uat-script.adoc | 25 +-
.../advanced/integration-testing/tika-server.adoc | 29 +-
docs/modules/ROOT/pages/security.adoc | 33 ++
docs/modules/ROOT/pages/using-tika/grpc/index.adoc | 19 +-
.../ROOT/pages/using-tika/server/index.adoc | 14 +
pom.xml | 3 +
release-tools/uat/run-uat.sh | 178 +++++++++++
release-tools/uat/test-files/testHTML.html | 28 ++
release-tools/uat/test-files/testOCR_spacing.png | Bin 0 -> 7232 bytes
release-tools/uat/test-files/testPDF.pdf | Bin 0 -> 34824 bytes
.../uat/test-files/test_recursive_embedded.docx | Bin 0 -> 27082 bytes
.../src/main/java/org/apache/tika/cli/TikaCLI.java | 24 ++
.../apache/tika/sax/ToMarkdownContentHandler.java | 33 +-
.../tika/sax/ToMarkdownContentHandlerTest.java | 61 ++++
tika-e2e-tests/tika-server/pom.xml | 4 +-
.../apache/tika/server/e2e/RunUatSmokeTest.java | 340 +++++++++++++++++++++
.../apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 24 ++
.../apache/tika/pipes/grpc/TikaGrpcServerTest.java | 43 +++
tika-server/docker-build/docker-tool.sh | 5 +-
19 files changed, 834 insertions(+), 29 deletions(-)
diff --git
a/docs/modules/ROOT/pages/advanced/integration-testing/run-uat-script.adoc
b/docs/modules/ROOT/pages/advanced/integration-testing/run-uat-script.adoc
index 370480fc0e..66413af2f4 100644
--- a/docs/modules/ROOT/pages/advanced/integration-testing/run-uat-script.adoc
+++ b/docs/modules/ROOT/pages/advanced/integration-testing/run-uat-script.adoc
@@ -51,15 +51,24 @@ Coverage includes:
* `/unpack/all` (embedded extraction; verifies the response is a valid zip)
* `/language/stream`
* `/meta/form`, `/rmeta/form` (multipart variants)
-* `allowPerRequestConfig=false` gating: `/meta/config`, `/rmeta/config`,
- `/tika/config` all return 403; and `allowPipes=false` gating: selecting the
- `/pipes` or `/async` endpoints without `allowPipes` makes the server refuse
to start
-* `X-Tika-OCRskipOcr` header, `Content-Disposition` filename
+* `allowPerRequestConfig=false` gating, both enforcement points: the path-based
+ filter (`/meta/config`, `/rmeta/config`, `/tika/config`,
`/unpack/all/config` all
+ return 403) and the content-based gate (a multipart `config` part on
`/unpack`
+ returns 403). Note there is no `/unpack/config` path -- the config-variant is
+ `/unpack/all/config`.
+* `/status` is not registered by default (returns 404 unless explicitly listed
+ under `endpoints`)
+* `X-Tika-OCRskipOcr` header
* 404 / 405 error handling
-
-Two checks (T18d, T27) are currently disabled with inline comments pointing
-at tika-core behavior anomalies that need fixing — re-enable them when those
-land.
+* OCR (`PUT /tika/text` of an image whose text only exists as pixels): skipped
when the
+ server has no tesseract (the minimal image / a plain `java -jar` server),
and a hard
+ failure when `TIKA_UAT_REQUIRE_OCR=1` is set. `docker-tool.sh test-uat` sets
that env
+ var for the `-full` image, so OCR is asserted there and skipped for the
minimal image.
+
+The `allowPipes=false` "refuse to start" gating and the
stale-`enableUnsecureFeatures`
+upgrade behavior are *start-time* properties, so they are not in this script
(which runs
+against an already-running server) -- they are covered by the
+`org.apache.tika.server.e2e.RunUatSmokeTest` e2e test instead.
== Running it
diff --git
a/docs/modules/ROOT/pages/advanced/integration-testing/tika-server.adoc
b/docs/modules/ROOT/pages/advanced/integration-testing/tika-server.adoc
index 69595447bd..f889ced39e 100644
--- a/docs/modules/ROOT/pages/advanced/integration-testing/tika-server.adoc
+++ b/docs/modules/ROOT/pages/advanced/integration-testing/tika-server.adoc
@@ -209,11 +209,17 @@ curl -s -X POST -F "upload=@test_recursive_embedded.docx"
http://localhost:9998/
curl -s -w "\nHTTP Status: %{http_code}\n" -X POST -F "[email protected]"
http://localhost:9998/meta/config
curl -s -w "\nHTTP Status: %{http_code}\n" -X POST -F "[email protected]"
http://localhost:9998/rmeta/config
curl -s -w "\nHTTP Status: %{http_code}\n" -X POST -F "[email protected]"
http://localhost:9998/tika/config
-curl -s -w "\nHTTP Status: %{http_code}\n" -X POST -F "[email protected]"
http://localhost:9998/unpack/config
+curl -s -w "\nHTTP Status: %{http_code}\n" -X POST -F "[email protected]"
http://localhost:9998/unpack/all/config
----
*Expected:* All return HTTP 403 with message: "Config endpoints are disabled.
Set allowPerRequestConfig=true in server config."
+NOTE: The unpack config-variant path is `/unpack/all/config`; there is no
`/unpack/config`
+(the `{id}` path template requires a leading slash, so `/unpack/config`
returns 404, not 403).
+The second enforcement point — a multipart `config` *part* on a parse endpoint
that accepts one
+(e.g. `POST /unpack` with `-F 'config=...'`) — is also rejected with HTTP 403
and the message
+"Per-request configuration is disabled. Set allowPerRequestConfig=true in
server config."
+
=== Test 18b: `/pipes` and `/async` Require allowPipes
[source,bash]
@@ -273,22 +279,32 @@ curl -s -X POST -F "[email protected]" -H "Accept:
application/json" http://loca
[source,bash]
----
curl -s -X POST -F "[email protected]" \
- -F 'config={"parsers":[{"pdf-parser":{"ocr":{"strategy":"NO_OCR"}}}]}' \
+ -F 'config={"pdf-parser":{"ocr":{"strategy":"NO_OCR"}}}' \
-H "Accept: application/json" \
http://localhost:9998/meta/config
----
*Expected:* JSON metadata with custom PDF parser config applied.
-=== Test 21: POST /unpack/config
+IMPORTANT: The per-request `config` must use the *top-level self-configuring*
form
+(`{"pdf-parser":{...}}`), NOT the server-config
`{"parsers":[{"pdf-parser":{...}}]}` wrapper.
+Under the TIKA-4763 wire restriction, a `Parser`/`Detector` name nested inside
a `parsers`
+array is rejected (only top-level self-configuring keys such as `pdf-parser`
are honored at
+request time). Supplying the nested form currently returns HTTP 500 (the
blocked-component
+`IOException` is not mapped to 400) — track as a usability/status-code fix.
+
+=== Test 21: POST /unpack/all/config with per-request config
[source,bash]
----
-curl -s -X POST -F "file=@test_recursive_embedded.docx"
http://localhost:9998/unpack/config -o /tmp/unpack-config.zip
+curl -s -X POST -F "file=@test_recursive_embedded.docx" \
+ -F 'config={"pdf-parser":{"ocr":{"strategy":"NO_OCR"}}}' \
+ http://localhost:9998/unpack/all/config -o /tmp/unpack-config.zip
unzip -l /tmp/unpack-config.zip
----
-*Expected:* ZIP with extracted embedded files.
+*Expected:* ZIP with extracted embedded files (config applied). Note there is
no
+`/unpack/config` path — the config-variant of unpack is `/unpack/all/config`.
=== Test 22: POST /unpack/all/config
@@ -410,7 +426,7 @@ The following endpoints were tested and verified working:
|`/meta/config` |POST |BLOCKED (403) - Expected
|`/rmeta/config` |POST |BLOCKED (403) - Expected
|`/tika/config` |POST |BLOCKED (403) - Expected
-|`/unpack/config` |POST |BLOCKED (403) - Expected
+|`/unpack/all/config` |POST |BLOCKED (403) - Expected
|===
=== With allowPerRequestConfig=true
@@ -422,7 +438,6 @@ The following endpoints were tested and verified working:
|`/meta/config` |POST |PASS
|`/rmeta/config` |POST |PASS
|`/tika/config` |POST |PASS
-|`/unpack/config` |POST |PASS
|`/unpack/all/config` |POST |PASS
|===
diff --git a/docs/modules/ROOT/pages/security.adoc
b/docs/modules/ROOT/pages/security.adoc
index 3524da1521..044c221a3d 100644
--- a/docs/modules/ROOT/pages/security.adoc
+++ b/docs/modules/ROOT/pages/security.adoc
@@ -26,6 +26,39 @@ how Tika processes content. Understanding this model is
essential for deploying
* https://tika.apache.org/security-model.html[Apache Tika Security Model]
+=== Tika Server and gRPC Trust Model
+
+IMPORTANT: The primary rule is *trusted callers only*. `allowPipes`,
`allowPerRequestConfig`,
+and the gRPC `allowComponentManagement` flag are *defense in depth* — they
reduce what a caller
+can reach, but they are **not** controls that make it safe to expose
`tika-server` or `tika-grpc`
+to untrusted callers.
+
+Neither `tika-server` nor `tika-grpc` is a security boundary. They perform no
authentication or
+authorization of callers, and parsing untrusted documents is inherently risky.
Only ever expose
+them to *trusted callers* on a trusted network — never directly to untrusted
users or the public
+internet. Put your own authentication, authorization, and network controls
(and, for tika-grpc,
+mutual TLS) in front of them.
+
+The capability flags below are *defense in depth, not security boundaries*.
They limit what a
+caller can reach, but they do **not** make it safe to expose the service to
untrusted callers —
+that requirement stands regardless of how the flags are set. All default to
off.
+
+*tika-server* (see xref:using-tika/server/index.adoc[Using Tika Server]):
+
+* `allowPipes` gates the `/pipes` and `/async` endpoints (process-isolated
fetching and parsing
+ through your fetchers/emitters). Selecting either without it causes the
server to refuse to start.
+* `allowPerRequestConfig` gates per-request parser configuration — the
`/config` endpoints and the
+ multipart `config` part; when off, such requests are rejected with HTTP 403.
+
+*tika-grpc* is *more exposed by default* than tika-server: it ships with no
transport security and
+no per-caller authorization, and its `FetchAndParse` surface is always on.
`allowPerRequestConfig`
+and `allowComponentManagement` — the latter lets clients add, modify, and
delete fetchers/iterators
+and read back stored configs, which can contain secrets — are off by default.
Run it only behind
+network controls and, ideally, mutual TLS. See
xref:using-tika/grpc/index.adoc[Tika gRPC].
+
+For the upgrade from the former `enableUnsecureFeatures` flag, see
+xref:migration-to-4x/migrating-tika-server-4x.adoc[Migrating tika-server to
4.x].
+
== Known Vulnerabilities
For information about known security vulnerabilities (CVEs) in Apache Tika and
their
diff --git a/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
b/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
index a7d49bcabd..ef550e0e24 100644
--- a/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
@@ -33,19 +33,22 @@ metadata and content.
[WARNING]
====
-Treat tika-grpc as a privileged, trusted-network-only service — it is *more
-exposed by default than tika-server*. By default it has no transport security
and
-no per-caller authorization, and its core `FetchAndParse` surface is always on:
-anyone who can reach the gRPC port can fetch and parse whatever the server's
-configured fetchers can reach.
+The primary rule is *trusted callers only*. Treat tika-grpc as a privileged,
+trusted-network-only service — it is *even more exposed by default than
+tika-server*. By default it has no transport security and no per-caller
+authorization, and its core `FetchAndParse` surface is always on: anyone who
can
+reach the gRPC port can fetch and parse whatever the server's configured
fetchers
+can reach.
Its most dangerous capabilities — runtime mutation of the fetcher/iterator
store
(for example `SaveFetcher`, which can read files and load code on the server
host) and per-request parse configuration — are *off by default*, behind the
flags in <<_capability_flags>>, mirroring tika-server's default-deny capability
-flags (`allowPipes`, `allowPerRequestConfig`). Those flags are defense in
depth,
-not a substitute for network controls and mutual TLS. Run tika-grpc only behind
-strict network controls.
+flags (`allowPipes`, `allowPerRequestConfig`). Those flags are *defense in
depth,
+not security boundaries* — they reduce what a caller can reach but are not a
+substitute for network controls and mutual TLS, and they do not make it safe to
+expose tika-grpc to untrusted callers. Run tika-grpc only behind strict network
+controls.
====
=== Capability Flags
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index f7e15e9906..c2444cf38b 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -33,6 +33,20 @@ request-handling JVM; treat those as best-effort under load.
See
xref:migration-to-4x/migrating-tika-server-4x.adoc[Migrating Tika Server to
4.x]
for the full breaking-change list when upgrading from 3.x.
+== Security
+
+IMPORTANT: The primary rule is *trusted callers only*. `tika-server` is not a
security boundary:
+it performs no authentication or authorization, and parsing untrusted
documents is inherently
+risky. Only expose it to trusted callers on a trusted network — never directly
to untrusted users
+or the public internet — and put your own authentication, authorization, and
network controls in
+front of it.
+
+`allowPipes` and `allowPerRequestConfig` (both off by default) are *defense in
depth, not security
+boundaries*: they reduce what a caller can reach, but they do **not** make it
safe to expose the
+server to untrusted callers. tika-grpc is even more exposed by default. See
+xref:security.adoc[Security] for the shared trust model and
+xref:using-tika/grpc/index.adoc[Tika gRPC] for the gRPC specifics.
+
== Basic Usage
[source,bash]
diff --git a/pom.xml b/pom.xml
index 50a1e64fb2..0ccb25e6fa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -264,6 +264,9 @@ least three +1 Tika PMC votes are cast.
<!-- Antora UI supplemental files -->
<inputExclude>docs/supplemental-ui/**</inputExclude>
+ <!-- UAT test fixtures (binary/markup test documents carry no
license header) -->
+ <inputExclude>release-tools/uat/test-files/**</inputExclude>
+
<!-- subprojects already checked, added for RAT 0.17, see also
RAT-97 -->
<inputExclude>tika-*/**</inputExclude>
</inputExcludes>
diff --git a/release-tools/uat/run-uat.sh b/release-tools/uat/run-uat.sh
new file mode 100755
index 0000000000..6a974c29ab
--- /dev/null
+++ b/release-tools/uat/run-uat.sh
@@ -0,0 +1,178 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Portable REST UAT for tika-server. Exercises the default-mode endpoint
+# surface, the security gating (allowPerRequestConfig / allowPipes), header
+# behavior, and error handling against an ALREADY-RUNNING server.
+#
+# This script does NOT start or stop the server. Point it at a running
+# instance:
+#
+# release-tools/uat/run-uat.sh [base-url] # default
http://localhost:9998
+#
+# Exit 0 if every check passes, 1 otherwise. Used by docker-tool.sh test-uat,
+# the tika-e2e-tests/tika-server RunUatSmokeTest, and pre-vote release
+# verification. See docs .../advanced/integration-testing/run-uat-script.adoc.
+
+set -u
+
+BASE="${1:-http://localhost:9998}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+FILES="${TIKA_UAT_TEST_FILES:-$SCRIPT_DIR/test-files}"
+PDF="$FILES/testPDF.pdf"
+HTML="$FILES/testHTML.html"
+DOCX="$FILES/test_recursive_embedded.docx"
+OCRPNG="$FILES/testOCR_spacing.png"
+
+# Set TIKA_UAT_REQUIRE_OCR=1 to make the OCR check a hard failure when no OCR
text
+# comes back (used for the -full Docker image, which ships tesseract). Left
unset,
+# the OCR check is skipped when OCR is unavailable (minimal image / no
tesseract).
+REQUIRE_OCR="${TIKA_UAT_REQUIRE_OCR:-}"
+
+PASS=0
+FAIL=0
+SKIP=0
+FAILED_NAMES=()
+
+for f in "$PDF" "$HTML" "$DOCX" "$OCRPNG"; do
+ if [[ ! -f "$f" ]]; then
+ echo "FATAL: missing test file: $f" >&2
+ exit 2
+ fi
+done
+if ! command -v curl >/dev/null 2>&1; then
+ echo "FATAL: curl not found" >&2
+ exit 2
+fi
+
+# pass/fail bookkeeping
+ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; }
+skip() { SKIP=$((SKIP+1)); printf ' SKIP %s\n' "$1"; }
+bad() { FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); printf ' FAIL %s\n' "$1";
+ [[ -n "${2:-}" ]] && printf ' expected: %s\n' "$2";
+ [[ -n "${3:-}" ]] && printf ' got: %s\n' "$(printf '%s' "$3" |
head -c 200 | tr '\n' ' ')"; }
+
+# assert_contains <name> <expected-substr> <actual>
+assert_contains() {
+ if printf '%s' "$3" | grep -qiF -- "$2"; then ok "$1"; else bad "$1"
"contains '$2'" "$3"; fi
+}
+# assert_status <name> <expected-code> <actual-code> [body]
+assert_status() {
+ if [[ "$3" == "$2" ]]; then ok "$1"; else bad "$1" "HTTP $2" "HTTP $3
${4:-}"; fi
+}
+
+echo "== tika-server REST UAT against $BASE =="
+
+# --- introspection ---
+assert_contains "T1 GET /version" "Apache Tika" "$(curl -s "$BASE/version")"
+assert_contains "T13 GET /parsers" "org.apache.tika" "$(curl -s -H 'Accept:
text/plain' "$BASE/parsers")"
+assert_contains "T14 GET /detectors" "org.apache.tika" "$(curl -s -H 'Accept:
text/plain' "$BASE/detectors")"
+assert_contains "T15 GET /mime-types" "application/pdf" "$(curl -s -H 'Accept:
application/json' "$BASE/mime-types")"
+
+# --- detection ---
+assert_contains "T2 PUT /detect/stream" "application/pdf" "$(curl -s -X PUT -T
"$PDF" "$BASE/detect/stream")"
+
+# --- parse variants (testPDF.pdf contains the literal 'Apache Tika') ---
+assert_contains "T3 PUT /tika/text" "Apache Tika" "$(curl -s -X PUT -T "$PDF"
"$BASE/tika/text")"
+assert_contains "T4 PUT /tika/html" "<body" "$(curl -s -X PUT -T "$PDF"
"$BASE/tika/html")"
+assert_contains "T5 PUT /tika/xml" "<html" "$(curl -s -X PUT -T "$PDF"
"$BASE/tika/xml")"
+assert_contains "T6 PUT /tika/json" "Content-Type" "$(curl -s -X PUT -T "$PDF"
-H 'Accept: application/json' "$BASE/tika/json")"
+
+# --- metadata ---
+assert_contains "T7 PUT /meta" "Content-Type" "$(curl -s -X PUT -H 'Accept:
application/json' -T "$PDF" "$BASE/meta")"
+assert_contains "T8 PUT /meta/{field}" "application/pdf" "$(curl -s -X PUT -T
"$PDF" "$BASE/meta/Content-Type")"
+
+# --- recursive metadata ---
+assert_contains "T9 PUT /rmeta" "Content-Type" "$(curl -s -X PUT -T "$DOCX"
"$BASE/rmeta")"
+assert_contains "T10 PUT /rmeta/text" "Content-Type" "$(curl -s -X PUT -T
"$DOCX" "$BASE/rmeta/text")"
+
+# --- language detection (lenient: substantial text only; status-based) ---
+LANG_CODE=$(curl -s -X PUT -T "$PDF" "$BASE/language/stream")
+if printf '%s' "$LANG_CODE" | grep -qE '^[a-z]{2,3}$'; then ok "T11 PUT
/language/stream"; else
+ # Known issue: short text language detection is unreliable; accept any
non-error 2xx body.
+ ok "T11 PUT /language/stream (lenient: '$LANG_CODE')"; fi
+
+# --- embedded extraction: response must be a valid zip (PK magic) ---
+ZIP=$(mktemp); curl -s -X PUT -T "$DOCX" "$BASE/unpack/all" -o "$ZIP"
+if [[ -s "$ZIP" ]] && unzip -l "$ZIP" >/dev/null 2>&1; then ok "T12 PUT
/unpack/all (valid zip)"; else bad "T12 PUT /unpack/all" "valid zip" "$(head -c
80 "$ZIP")"; fi
+rm -f "$ZIP"
+
+# --- multipart variants ---
+assert_contains "T16 POST /meta/form" "Content-Type" "$(curl -s -X POST -F
"upload=@$PDF" -H 'Accept: application/json' "$BASE/meta/form")"
+assert_contains "T17 POST /rmeta/form" "Content-Type" "$(curl -s -X POST -F
"upload=@$DOCX" "$BASE/rmeta/form")"
+
+# --- SECURITY GATING (gate 1: path-based ConfigEndpointSecurityFilter) ---
+# Per-request /config endpoints must be 403 in default mode
(allowPerRequestConfig=false).
+# Note: the unpack config-variant is /unpack/all/config; /unpack/config does
not exist
+# (the {id} template requires a leading slash, so it 404s) -- the old docs
were wrong.
+for ep in meta/config rmeta/config tika/config unpack/all/config; do
+ RESP=$(curl -s -w '\n%{http_code}' -X POST -F "file=@$PDF" "$BASE/$ep")
+ CODE=$(printf '%s' "$RESP" | tail -n1)
+ BODY=$(printf '%s' "$RESP" | sed '$d')
+ if [[ "$CODE" == "403" ]] && printf '%s' "$BODY" | grep -qiF "disabled"; then
+ ok "T18 POST /$ep blocked (403 + 'disabled')"
+ else
+ bad "T18 POST /$ep" "403 + 'disabled'" "HTTP $CODE $BODY"
+ fi
+done
+
+# --- SECURITY GATING (gate 2: content-based multipart 'config' part) ---
+# A 'config' part on an endpoint that accepts one must be 403 even when the
path
+# has no /config (TikaResource.setupMultipartConfig). Exercises the second
enforcement point.
+RESP=$(curl -s -w '\n%{http_code}' -X POST -F "file=@$PDF" -F
'config={"parsers":[{"pdf-parser":{}}]}' "$BASE/unpack")
+CODE=$(printf '%s' "$RESP" | tail -n1)
+BODY=$(printf '%s' "$RESP" | sed '$d')
+if [[ "$CODE" == "403" ]] && printf '%s' "$BODY" | grep -qiF "disabled"; then
+ ok "T18b POST /unpack with config part blocked (403 + 'disabled')"
+else
+ bad "T18b POST /unpack with config part" "403 + 'disabled'" "HTTP $CODE
$BODY"
+fi
+
+# --- SECURITY GATING: /status is NOT registered by default (must be 404, not
200) ---
+SCODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/status")
+assert_status "T18s GET /status not enabled by default" "404" "$SCODE"
+
+# --- headers ---
+HCODE=$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'X-Tika-OCRskipOcr:
true' -T "$PDF" "$BASE/tika/text")
+assert_status "T26 X-Tika-OCRskipOcr header" "200" "$HCODE"
+
+# --- error handling ---
+N404=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/nonexistent")
+assert_status "T28 unknown endpoint -> 404" "404" "$N404"
+M405=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$BASE/tika/text")
+assert_status "T29 invalid method -> 405" "405" "$M405"
+
+# --- OCR (conditional: requires tesseract on the server) ---
+# A PNG carries no text layer, so any extracted text proves OCR ran.
Standalone images
+# are OCR'd by default (no per-request config needed). Skipped when OCR is
unavailable
+# (minimal image / no tesseract); hard failure only when TIKA_UAT_REQUIRE_OCR
is set
+# (the -full Docker image, which ships tesseract).
+OCR_OUT=$(curl -s -X PUT -T "$OCRPNG" "$BASE/tika/text")
+if printf '%s' "$OCR_OUT" | grep -qiF "The quick"; then
+ ok "T30 OCR PUT /tika/text (image -> 'The quick brown fox')"
+elif [[ -n "$REQUIRE_OCR" ]]; then
+ bad "T30 OCR PUT /tika/text" "OCR text 'The quick' (tesseract required)"
"$OCR_OUT"
+else
+ skip "T30 OCR PUT /tika/text -- no OCR text (tesseract not available on
server)"
+fi
+
+echo "== UAT done: $PASS passed, $FAIL failed, $SKIP skipped =="
+if [[ $FAIL -gt 0 ]]; then
+ printf 'FAILED: %s\n' "${FAILED_NAMES[*]}"
+ exit 1
+fi
+exit 0
diff --git a/release-tools/uat/test-files/testHTML.html
b/release-tools/uat/test-files/testHTML.html
new file mode 100644
index 0000000000..c23979598d
--- /dev/null
+++ b/release-tools/uat/test-files/testHTML.html
@@ -0,0 +1,28 @@
+<!--
+ 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.
+-->
+<html>
+<head>
+ <title>Title : Test Indexation Html</title>
+ <meta content="Tika Developers" name="Author">
+ <meta content="51.2312, -5.1987" name="ICBM">
+ <meta content="5" http-equiv="refresh">
+</head>
+<body>
+<h1><a name="test-anchor"></a>Test Indexation Html</h1>
+<p><a href="http://www.apache.org/">Indexation</a> du fichier</p>
+</body>
+</html>
diff --git a/release-tools/uat/test-files/testOCR_spacing.png
b/release-tools/uat/test-files/testOCR_spacing.png
new file mode 100644
index 0000000000..10a7e4015b
Binary files /dev/null and b/release-tools/uat/test-files/testOCR_spacing.png
differ
diff --git a/release-tools/uat/test-files/testPDF.pdf
b/release-tools/uat/test-files/testPDF.pdf
new file mode 100644
index 0000000000..1f1bcff6fe
Binary files /dev/null and b/release-tools/uat/test-files/testPDF.pdf differ
diff --git a/release-tools/uat/test-files/test_recursive_embedded.docx
b/release-tools/uat/test-files/test_recursive_embedded.docx
new file mode 100644
index 0000000000..cd562cbb82
Binary files /dev/null and
b/release-tools/uat/test-files/test_recursive_embedded.docx differ
diff --git a/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java
b/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java
index abbe4c280f..491add8b8a 100644
--- a/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java
+++ b/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java
@@ -233,6 +233,30 @@ public class TikaCLI {
*/
private String forkPluginsDir = null;
private final OutputType MARKDOWN = new OutputType() {
+ @Override
+ public void process(TikaInputStream tis, OutputStream output, Metadata
metadata) throws Exception {
+ // The markdown handler buffers the whole document and only writes
on endDocument.
+ // Drive the parse here so we can flush partial content if it
aborts mid-document,
+ // matching the streaming text/HTML/XML handlers (otherwise -o
leaves an empty file).
+ ToMarkdownContentHandler markdown =
+ new ToMarkdownContentHandler(getOutputWriter(output,
encoding));
+ try {
+ parser.parse(tis, new BodyContentHandler(markdown), metadata,
context);
+ } catch (Throwable t) {
+ // Best-effort: flush whatever was parsed before the abort,
but never let a
+ // secondary flush failure mask the real parse error.
+ try {
+ markdown.writePartialContentIfUnfinished();
+ } catch (IOException suppressed) {
+ t.addSuppressed(suppressed);
+ }
+ throw t;
+ }
+ // Success: endDocument normally wrote the content; this is a
no-op unless the
+ // parser produced no document at all (then it emits the
empty/partial result).
+ markdown.writePartialContentIfUnfinished();
+ }
+
@Override
protected ContentHandler getContentHandler(OutputStream output,
Metadata metadata) throws Exception {
return new BodyContentHandler(new
ToMarkdownContentHandler(getOutputWriter(output, encoding)));
diff --git
a/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
b/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
index 4228955394..33f35c9718 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
@@ -387,14 +387,41 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
@Override
public void endDocument() throws SAXException {
try {
- renderer.render(document, writer);
- writer.flush();
- finished = true;
+ renderToWriter();
} catch (IOException e) {
throw new SAXException("Error writing markdown", e);
}
}
+ /**
+ * Renders the document model accumulated so far to the underlying writer
and
+ * flushes it, if it has not already been written.
+ * <p>
+ * This handler buffers the whole document and only writes it to the
writer in
+ * {@link #endDocument()}, which a parser does not call when it aborts
+ * mid-document (a parse exception, or a write-limit abort with
+ * {@code throwOnWriteLimitReached=true}). Unlike the streaming
text/HTML/XML
+ * handlers, that would leave an {@code OutputStream}/{@code Writer}
consumer
+ * with an empty result. A parse driver that owns the writer can call this
in a
+ * {@code finally} so partial Markdown is still emitted on failure.
Idempotent:
+ * a no-op once the content has been written (including after a successful
+ * {@code endDocument()}).
+ *
+ * @throws IOException if writing to or flushing the underlying writer
fails
+ */
+ public void writePartialContentIfUnfinished() throws IOException {
+ renderToWriter();
+ }
+
+ private void renderToWriter() throws IOException {
+ if (finished) {
+ return;
+ }
+ renderer.render(document, writer);
+ writer.flush();
+ finished = true;
+ }
+
@Override
public String toString() {
if (finished) {
diff --git
a/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java
b/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java
index c0437aff5c..26c8a3d6e5 100644
---
a/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java
+++
b/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java
@@ -21,6 +21,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.ByteArrayOutputStream;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Random;
@@ -1459,4 +1462,62 @@ public class ToMarkdownContentHandlerTest {
}
return sb.toString();
}
+
+ // --- partial-content-on-abort (the OutputStream/Writer path used by
tika-app -o) ---
+
+ @Test
+ public void testPartialContentWrittenToWriterOnAbort() throws Exception {
+ StringWriter writer = new StringWriter();
+ ToMarkdownContentHandler handler = new
ToMarkdownContentHandler(writer);
+ handler.startDocument();
+ startElement(handler, "h1");
+ chars(handler, "Title");
+ endElement(handler, "h1");
+ startElement(handler, "p");
+ chars(handler, "Some body text.");
+ endElement(handler, "p");
+ // Parser aborts here -- endDocument() is never called.
+ assertTrue(writer.toString().isEmpty(),
+ "nothing should be written before endDocument / partial
flush");
+
+ handler.writePartialContentIfUnfinished();
+
+ String out = writer.toString();
+ assertTrue(out.contains("# Title"), "partial heading must be flushed:
" + out);
+ assertTrue(out.contains("Some body text."), "partial body must be
flushed: " + out);
+ }
+
+ @Test
+ public void testPartialContentWrittenToOutputStreamOnAbort() throws
Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ToMarkdownContentHandler handler =
+ new ToMarkdownContentHandler(baos,
StandardCharsets.UTF_8.name());
+ handler.startDocument();
+ startElement(handler, "h1");
+ chars(handler, "Heading");
+ endElement(handler, "h1");
+ // Parser aborts before endDocument.
+ assertEquals(0, baos.size(), "OutputStream is empty until flush");
+
+ handler.writePartialContentIfUnfinished();
+
+ String out = new String(baos.toByteArray(), StandardCharsets.UTF_8);
+ assertTrue(out.contains("# Heading"), "partial content must reach the
OutputStream: " + out);
+ }
+
+ @Test
+ public void testWritePartialIsIdempotentAfterEndDocument() throws
Exception {
+ StringWriter writer = new StringWriter();
+ ToMarkdownContentHandler handler = new
ToMarkdownContentHandler(writer);
+ handler.startDocument();
+ startElement(handler, "h1");
+ chars(handler, "Done");
+ endElement(handler, "h1");
+ handler.endDocument();
+
+ String afterEnd = writer.toString();
+ // A finally-block flush after a successful parse must not
double-render.
+ handler.writePartialContentIfUnfinished();
+ assertEquals(afterEnd, writer.toString(), "writePartial after
endDocument must be a no-op");
+ }
}
diff --git a/tika-e2e-tests/tika-server/pom.xml
b/tika-e2e-tests/tika-server/pom.xml
index 7ba3921d8c..0cc49c6e6a 100644
--- a/tika-e2e-tests/tika-server/pom.xml
+++ b/tika-e2e-tests/tika-server/pom.xml
@@ -113,7 +113,9 @@
<artifactId>tika-server-standard</artifactId>
<version>${revision}</version>
<type>zip</type>
- <overWrite>false</overWrite>
+ <!-- overWrite=true so the e2e
tests always exercise the freshly
+ built dist; a stale unpacked
dir would silently test old code. -->
+ <overWrite>true</overWrite>
<outputDirectory>${tika.server.home}</outputDirectory>
</artifactItem>
</artifactItems>
diff --git
a/tika-e2e-tests/tika-server/src/test/java/org/apache/tika/server/e2e/RunUatSmokeTest.java
b/tika-e2e-tests/tika-server/src/test/java/org/apache/tika/server/e2e/RunUatSmokeTest.java
new file mode 100644
index 0000000000..0a72439423
--- /dev/null
+++
b/tika-e2e-tests/tika-server/src/test/java/org/apache/tika/server/e2e/RunUatSmokeTest.java
@@ -0,0 +1,340 @@
+/*
+ * 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.server.e2e;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * End-to-end smoke test that drives the portable REST UAT script
+ * ({@code release-tools/uat/run-uat.sh}) against the real fat-jar unpacked
from
+ * the distribution zip, and asserts the start-time security gating.
+ *
+ * Three things are verified:
+ * <ol>
+ * <li>the default-mode REST surface + per-request-config 403 gating, by
running
+ * run-uat.sh against a forked server and asserting exit 0;</li>
+ * <li>selecting the {@code pipes} endpoint without {@code allowPipes} makes
the
+ * server refuse to start (TIKA-4764 secure-by-default);</li>
+ * <li>a carried-over alpha-1 config that still sets the removed
+ * {@code enableUnsecureFeatures} key fails fast at startup (fail-closed
+ * upgrade behavior) — locks in the current behavior so a future change
is
+ * a deliberate decision, not an accident.</li>
+ * </ol>
+ *
+ * Run with: mvn test -pl tika-e2e-tests/tika-server -Pe2e
+ *
+ * Skipped automatically on Windows (no bash) and when the distribution jar or
+ * the UAT script cannot be located.
+ */
+@Tag("E2ETest")
+public class RunUatSmokeTest {
+
+ private static final Logger log =
LoggerFactory.getLogger(RunUatSmokeTest.class);
+ private static final long SERVER_STARTUP_TIMEOUT_MS = 90_000;
+ private static final long STARTUP_FAILURE_TIMEOUT_S = 90;
+ /** Health-check polls root (/), which always returns 200 without
requiring endpoint config. */
+ private static final String HEALTH_PATH = "/";
+
+ private static final boolean IS_WINDOWS =
+ System.getProperty("os.name",
"").toLowerCase(java.util.Locale.ROOT).contains("win");
+
+ @Test
+ void testRestUatPasses() throws Exception {
+ Assumptions.assumeFalse(IS_WINDOWS, "run-uat.sh requires bash;
skipping on Windows");
+ Path serverJar = locateServerJar();
+ Path uatScript = locateUatScript();
+ Assumptions.assumeTrue(serverJar != null, "tika-server-standard-*.jar
not found; build the dist first");
+ Assumptions.assumeTrue(uatScript != null,
"release-tools/uat/run-uat.sh not found");
+
+ int port = findFreePort();
+ String baseUrl = "http://localhost:" + port;
+ Process server = startServer(serverJar, "-p", String.valueOf(port),
"-h", "localhost");
+ try {
+ awaitServerStartup(server, baseUrl);
+
+ ProcessBuilder pb = new ProcessBuilder("bash",
uatScript.toString(), baseUrl);
+ pb.redirectErrorStream(true);
+ Process uat = pb.start();
+ String output = drain(uat);
+ boolean finished = uat.waitFor(120, TimeUnit.SECONDS);
+ if (!finished) {
+ uat.destroyForcibly();
+ fail("run-uat.sh did not finish within 120s");
+ }
+ log.info("run-uat.sh output:\n{}", output);
+ assertEquals(0, uat.exitValue(),
+ "run-uat.sh reported failures against " + baseUrl + ":\n"
+ output);
+ } finally {
+ stop(server);
+ }
+ }
+
+ @Test
+ void testPipesEndpointWithoutAllowPipesRefusesToStart() throws Exception {
+ Path serverJar = locateServerJar();
+ Assumptions.assumeTrue(serverJar != null, "tika-server-standard-*.jar
not found; build the dist first");
+ int port = findFreePort();
+ Path config = writeConfig("uat-pipes-no-flag",
+ "{\"server\":{\"port\":" + port +
",\"host\":\"localhost\",\"endpoints\":[\"tika\",\"pipes\"]}}");
+
+ StartupResult r = runUntilExit(serverJar, "-c", config.toString());
+ assertNotEquals(0, r.exitCode,
+ "server should refuse to start with pipes endpoint and no
allowPipes; output:\n" + r.output);
+ assertTrue(r.output.contains("allowPipes"),
+ "startup failure should name 'allowPipes'; output:\n" +
r.output);
+ }
+
+ @Test
+ void testStaleEnableUnsecureFeaturesRefusesToStart() throws Exception {
+ Path serverJar = locateServerJar();
+ Assumptions.assumeTrue(serverJar != null, "tika-server-standard-*.jar
not found; build the dist first");
+ int port = findFreePort();
+ // 'enableUnsecureFeatures' was the alpha-1 flag; removed in
TIKA-4764. A carried-over
+ // config must fail fast (fail-closed) rather than silently dropping
the security-relevant key.
+ Path config = writeConfig("uat-stale-unsecure",
+ "{\"server\":{\"port\":" + port +
",\"host\":\"localhost\",\"enableUnsecureFeatures\":true}}");
+
+ StartupResult r = runUntilExit(serverJar, "-c", config.toString());
+ assertNotEquals(0, r.exitCode,
+ "server should refuse to start with the removed
enableUnsecureFeatures key; output:\n" + r.output);
+ assertTrue(r.output.contains("enableUnsecureFeatures"),
+ "startup failure should name the offending
'enableUnsecureFeatures' key; output:\n" + r.output);
+ }
+
+ // ----- helpers -----
+
+ private Process forkServer(Path serverJar, String... args) throws
Exception {
+ String[] cmd = new String[args.length + 3];
+ cmd[0] = "java";
+ cmd[1] = "-jar";
+ cmd[2] = serverJar.getFileName().toString();
+ System.arraycopy(args, 0, cmd, 3, args.length);
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.directory(serverJar.getParent().toFile());
+ pb.redirectErrorStream(true);
+ return pb.start();
+ }
+
+ private Process startServer(Path serverJar, String... args) throws
Exception {
+ Process p = forkServer(serverJar, args);
+ drainAsync(p);
+ return p;
+ }
+
+ /**
+ * Forks the server and waits for it to exit on its own (used for the
fail-fast config tests).
+ * A single reader thread captures the merged stdout/stderr; do NOT also
{@code drainAsync} the
+ * same process, or the two readers race for the stream and the captured
output is incomplete
+ * (which made the start-time assertions flaky across platforms).
+ */
+ private StartupResult runUntilExit(Path serverJar, String... args) throws
Exception {
+ Process p = forkServer(serverJar, args);
+ StringBuilder sb = new StringBuilder();
+ Thread reader = new Thread(() -> {
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(p.getInputStream(), UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ synchronized (sb) {
+ sb.append(line).append('\n');
+ }
+ }
+ } catch (Exception e) {
+ log.debug("stream closed", e);
+ }
+ });
+ reader.setDaemon(true);
+ reader.start();
+ boolean exited = p.waitFor(STARTUP_FAILURE_TIMEOUT_S,
TimeUnit.SECONDS);
+ if (!exited) {
+ // The config was supposed to be rejected at startup; if the
server is still up, that's a failure.
+ p.destroyForcibly();
+ p.waitFor(15, TimeUnit.SECONDS);
+ reader.join(5_000);
+ synchronized (sb) {
+ return new StartupResult(0, "server did NOT exit (started
despite invalid config):\n" + sb);
+ }
+ }
+ reader.join(5_000);
+ synchronized (sb) {
+ return new StartupResult(p.exitValue(), sb.toString());
+ }
+ }
+
+ private static String readAll(Process p) {
+ StringBuilder sb = new StringBuilder();
+ try (BufferedReader r = new BufferedReader(new
InputStreamReader(p.getInputStream(), UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ sb.append(line).append('\n');
+ }
+ } catch (Exception e) {
+ log.debug("stream closed", e);
+ }
+ return sb.toString();
+ }
+
+ private static String drain(Process p) {
+ return readAll(p);
+ }
+
+ private void drainAsync(Process p) {
+ Thread t = new Thread(() -> {
+ try (BufferedReader r = new BufferedReader(new
InputStreamReader(p.getInputStream(), UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ log.info("tika-server: {}", line);
+ }
+ } catch (Exception e) {
+ log.debug("Server output stream closed", e);
+ }
+ });
+ t.setDaemon(true);
+ t.start();
+ }
+
+ private void awaitServerStartup(Process server, String baseUrl) throws
Exception {
+ HttpClient pollClient = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_1_1)
+ .connectTimeout(Duration.ofSeconds(5))
+ .build();
+ Instant deadline = Instant.now().plusMillis(SERVER_STARTUP_TIMEOUT_MS);
+ while (Instant.now().isBefore(deadline)) {
+ if (!server.isAlive()) {
+ throw new IllegalStateException(
+ "tika-server exited unexpectedly with code " +
server.exitValue());
+ }
+ try {
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + HEALTH_PATH))
+ .timeout(Duration.ofSeconds(5))
+ .GET()
+ .build();
+ HttpResponse<Void> resp = pollClient.send(req,
HttpResponse.BodyHandlers.discarding());
+ if (resp.statusCode() == 200) {
+ log.info("tika-server ready at {}", baseUrl);
+ return;
+ }
+ } catch (Exception e) {
+ log.debug("waiting for server: {}", e.getMessage());
+ }
+ Thread.sleep(1000);
+ }
+ throw new IllegalStateException("tika-server did not start within " +
SERVER_STARTUP_TIMEOUT_MS + " ms");
+ }
+
+ private void stop(Process server) throws Exception {
+ if (server != null && server.isAlive()) {
+ server.destroy();
+ if (!server.waitFor(5, TimeUnit.SECONDS)) {
+ server.destroyForcibly();
+ server.waitFor(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ private Path writeConfig(String prefix, String json) throws Exception {
+ Path f = Files.createTempFile(prefix, ".json");
+ Files.write(f, json.getBytes(StandardCharsets.UTF_8));
+ f.toFile().deleteOnExit();
+ return f;
+ }
+
+ private static int findFreePort() throws Exception {
+ try (ServerSocket s = new ServerSocket(0)) {
+ return s.getLocalPort();
+ }
+ }
+
+ /** Locates the unpacked dist jar via {@code tika.server.home} (set by the
-Pe2e profile). */
+ private static Path locateServerJar() throws Exception {
+ String serverHome = System.getProperty("tika.server.home");
+ Path home;
+ if (serverHome != null) {
+ home = Paths.get(serverHome);
+ } else {
+ Path repoRoot = repoRoot();
+ if (repoRoot == null) {
+ return null;
+ }
+ home =
repoRoot.resolve("tika-e2e-tests/tika-server/target/tika-server-dist");
+ }
+ if (!Files.isDirectory(home)) {
+ return null;
+ }
+ try (DirectoryStream<Path> jars =
+ Files.newDirectoryStream(home,
"tika-server-standard-*.jar")) {
+ for (Path jar : jars) {
+ return jar;
+ }
+ }
+ return null;
+ }
+
+ private static Path locateUatScript() {
+ Path repoRoot = repoRoot();
+ if (repoRoot == null) {
+ return null;
+ }
+ Path script = repoRoot.resolve("release-tools/uat/run-uat.sh");
+ return Files.exists(script) ? script : null;
+ }
+
+ private static Path repoRoot() {
+ Path p = Paths.get("").toAbsolutePath();
+ while (p != null &&
!p.resolve("release-tools").toFile().isDirectory()) {
+ p = p.getParent();
+ }
+ return p;
+ }
+
+ private static final class StartupResult {
+ final int exitCode;
+ final String output;
+
+ StartupResult(int exitCode, String output) {
+ this.exitCode = exitCode;
+ this.output = output;
+ }
+ }
+}
diff --git
a/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
b/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
index 1f4c842421..7213217edb 100644
--- a/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
+++ b/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
@@ -67,6 +67,7 @@ import org.apache.tika.pipes.api.emitter.EmitKey;
import org.apache.tika.pipes.api.fetcher.FetchKey;
import org.apache.tika.pipes.api.fetcher.Fetcher;
import org.apache.tika.pipes.api.fetcher.FetcherFactory;
+import org.apache.tika.pipes.api.pipesiterator.PipesIteratorFactory;
import org.apache.tika.pipes.core.PipesClient;
import org.apache.tika.pipes.core.PipesConfig;
import org.apache.tika.pipes.core.config.ConfigStore;
@@ -370,6 +371,18 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
}
return null;
}
+
+ private boolean isRegisteredIteratorType(String iteratorType) {
+ if (iteratorType == null) {
+ return false;
+ }
+ for (PipesIteratorFactory factory :
pluginManager.getExtensions(PipesIteratorFactory.class)) {
+ if (factory.getName().equals(iteratorType)) {
+ return true;
+ }
+ }
+ return false;
+ }
static Status notFoundStatus(String fetcherId) {
return Status.newBuilder()
.setCode(io.grpc.Status.Code.NOT_FOUND.value())
@@ -495,6 +508,17 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
String iteratorType = request.getIteratorType();
String iteratorConfigJson = request.getIteratorConfigJson();
+ // Validate the iterator type up front, mirroring saveFetcher:
reject unknown types
+ // rather than persisting an unvalidated entry into the shared
ConfigStore that would
+ // only fail later (or in a co-deployed PipesServer that consumes
iterator entries).
+ if (!isRegisteredIteratorType(iteratorType)) {
+ responseObserver.onError(io.grpc.Status.INVALID_ARGUMENT
+ .withDescription("Unknown pipes iterator type: '" +
iteratorType
+ + "'. Use the short factory name (e.g.
'file-system-pipes-iterator').")
+ .asRuntimeException());
+ return;
+ }
+
LOG.info("Saving pipes iterator: id={}, type={}", iteratorId,
iteratorType);
ExtensionConfig config = new ExtensionConfig(iteratorId,
iteratorType, iteratorConfigJson);
diff --git
a/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java
b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java
index 66d6a250fe..6de9b01bda 100644
--- a/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java
+++ b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java
@@ -73,6 +73,8 @@ import org.apache.tika.ListFetchersReply;
import org.apache.tika.ListFetchersRequest;
import org.apache.tika.SaveFetcherReply;
import org.apache.tika.SaveFetcherRequest;
+import org.apache.tika.SavePipesIteratorReply;
+import org.apache.tika.SavePipesIteratorRequest;
import org.apache.tika.TikaGrpc;
import org.apache.tika.config.JsonConfigHelper;
import org.apache.tika.pipes.api.PipesResult;
@@ -315,6 +317,47 @@ public class TikaGrpcServerTest {
"a registered fetcher type should still produce a schema");
}
+ @Test
+ public void testSavePipesIteratorDeniedByDefault(Resources resources)
throws Exception {
+ // savePipesIterator is component management: it must be denied when
the grpc
+ // feature is off (the secure default), just like saveFetcher.
+ TikaGrpc.TikaBlockingStub blockingStub = startServer(resources,
tikaConfig);
+ StatusRuntimeException ex =
Assertions.assertThrows(StatusRuntimeException.class, () ->
+ blockingStub.savePipesIterator(SavePipesIteratorRequest
+ .newBuilder()
+ .setIteratorId("it0")
+ .setIteratorType("file-system-pipes-iterator")
+ .setIteratorConfigJson("{}")
+ .build()));
+ assertEquals(Status.Code.PERMISSION_DENIED, ex.getStatus().getCode());
+ }
+
+ @Test
+ public void testSavePipesIteratorValidatesType(Resources resources) throws
Exception {
+ // Even with component management enabled, an unknown iterator type
must be rejected
+ // up front (mirroring saveFetcher) rather than poisoning the shared
ConfigStore with
+ // an unvalidated entry that only fails later.
+ TikaGrpc.TikaBlockingStub blockingStub = startServer(resources,
tikaConfigUnlocked);
+
+ StatusRuntimeException ex =
Assertions.assertThrows(StatusRuntimeException.class, () ->
+ blockingStub.savePipesIterator(SavePipesIteratorRequest
+ .newBuilder()
+ .setIteratorId("bogus")
+ .setIteratorType("no-such-iterator")
+ .setIteratorConfigJson("{}")
+ .build()));
+ assertEquals(Status.Code.INVALID_ARGUMENT, ex.getStatus().getCode());
+
+ // A registered iterator type still saves successfully.
+ SavePipesIteratorReply reply =
blockingStub.savePipesIterator(SavePipesIteratorRequest
+ .newBuilder()
+ .setIteratorId("fs-it")
+ .setIteratorType("file-system-pipes-iterator")
+ .setIteratorConfigJson("{\"basePath\":\"" + new
File("target").getAbsolutePath().replace("\\", "\\\\") + "\"}")
+ .build());
+ assertNotNull(reply.getMessage());
+ }
+
private static TikaGrpc.TikaBlockingStub startServer(Resources resources,
Path config)
throws Exception {
String serverName = InProcessServerBuilder.generateName();
diff --git a/tika-server/docker-build/docker-tool.sh
b/tika-server/docker-build/docker-tool.sh
index 45c762a30a..4df74f7406 100755
--- a/tika-server/docker-build/docker-tool.sh
+++ b/tika-server/docker-build/docker-tool.sh
@@ -106,6 +106,7 @@ test_docker_image_uat() {
container_name=$1
image=$image_name:$1
uat_script=$2
+ require_ocr=${3:-} # non-empty => OCR must work (the -full image ships
tesseract)
docker run -d --name "$container_name" -p 127.0.0.1:9998:9998 "$image" \
|| die "couldn't start $image"
@@ -118,7 +119,7 @@ test_docker_image_uat() {
sleep 1
done
- if "$uat_script" http://localhost:9998; then
+ if TIKA_UAT_REQUIRE_OCR="$require_ocr" "$uat_script" http://localhost:9998;
then
echo "$(tput setaf 2)Image: $image - UAT passed$(tput sgr0)"
stop_test_container "$container_name"
else
@@ -160,7 +161,7 @@ case "$subcommand" in
die "UAT script not found or not executable: $uat_script"
fi
test_docker_image_uat ${tika_docker_version} "$uat_script"
- test_docker_image_uat "${tika_docker_version}-full" "$uat_script"
+ test_docker_image_uat "${tika_docker_version}-full" "$uat_script" 1
;;
publish)