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 75ebebc2b2 TIKA-4909: enable env variable interpolation in 
tika-config.json (#3200)
75ebebc2b2 is described below

commit 75ebebc2b28127d0e80eed25fe40ecaa5fe8bc41
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 18 16:02:04 2026 -0400

    TIKA-4909: enable env variable interpolation in tika-config.json (#3200)
---
 CHANGES.txt                                        |   2 +
 docs/modules/ROOT/pages/configuration/index.adoc   |  29 ++++++
 .../ROOT/pages/configuration/inference.adoc        |  13 +--
 .../pages/configuration/parsers/vlm-parsers.adoc   |   3 +-
 .../ROOT/pages/developers/custom-ocr-engine.adoc   |   2 +-
 .../inference-handler-requirements.adoc            |   2 +-
 .../ROOT/pages/pipes/plugins/elasticsearch.adoc    |   3 +-
 docs/modules/ROOT/pages/pipes/plugins/http.adoc    |   1 +
 .../ROOT/pages/pipes/plugins/opensearch.adoc       |   3 +-
 docs/modules/ROOT/pages/pipes/plugins/s3.adoc      |   3 +-
 docs/modules/ROOT/pages/using-tika/server/tls.adoc |  13 +--
 .../apache/tika/config/loader/EnvInterpolator.java |  98 +++++++++++++++++++
 .../apache/tika/config/loader/TikaJsonConfig.java  |   6 +-
 .../tika/config/loader/EnvInterpolatorTest.java    | 108 +++++++++++++++++++++
 .../core/PerRequestConfigNotInterpolatedTest.java  |  98 +++++++++++++++++++
 15 files changed, 365 insertions(+), 19 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 26446a0a4a..8650c09085 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
 Release 4.1.0 - unreleased
 
+   * Enable environment variable interpolation in tika-config.json (TIKA-4909).
+
    * Fix a cache-budget leak: tar, 7z and streamed zip entries never released
      their in-memory cache's reservation (TIKA-4908).
      
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index 541432897f..3d6aa1d0eb 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -256,6 +256,35 @@ engine still advertising them is treated as a text 
recognizer for the real types
 startup, until 5.0. Nothing else in Tika produces them; a 
`tk:content-type-parser-override`
 naming one matches no parser and falls to the empty fallback.
 
+[#env]
+== Secrets from the environment
+
+A string value in the startup config may name an environment variable as 
`${env:NAME}`, alone or
+inside a longer string, and the loader substitutes the variable's value when 
the config is read:
+
+[source,json]
+----
+{
+  "engines": {
+    "jina": { "openai-embedding-engine": { "baseUrl": "https://api.jina.ai";,
+              "apiKey": "${env:JINA_API_KEY}", "model": 
"jina-embeddings-v5-omni-small" } }
+  },
+  "emitters": {
+    "es": { "es-emitter": { "esUrl": "http://${env:ES_HOST}:9200/tika";, 
"apiKey": "${env:ES_API_KEY}" } }
+  }
+}
+----
+
+Only that exact form is touched: `${NAME}`, `$NAME`, `${sys:...}` and any 
other text stay literal,
+so a prompt or a pattern that happens to contain `${` is safe. A reference to 
a variable that is not
+set fails the load with the variable's name and the JSON path, never a value, 
so a missing secret
+stops the server at startup rather than reaching a service as an empty key. A 
literal value works as
+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.
+
 == Windows file paths
 
 JSON treats the backslash as an escape character, so path options 
(`tesseractPath`,
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc 
b/docs/modules/ROOT/pages/configuration/inference.adoc
index d31a72d2f2..f3c3e66a3b 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -75,7 +75,7 @@ rendering as a PNG, never the PDF itself, and the PDF parser 
renders only when i
 {
   "engines": {
     "jina": { "openai-embedding-engine": {
-      "baseUrl": "https://api.jina.ai";, "apiKey": "...",
+      "baseUrl": "https://api.jina.ai";, "apiKey": "${env:JINA_API_KEY}",
       "model": "jina-embeddings-v5-omni-small", "maxBatchSize": 16,
       "requestParameters": { "task": "retrieval.passage", "dimensions": 1024 } 
} }
   },
@@ -104,7 +104,7 @@ same engine with the defaults and `"baseUrl": 
"https://api.openai.com"`:
     "azure": { "openai-embedding-engine": {
       "baseUrl": "https://my-resource.openai.azure.com";,
       "embeddingsPath": 
"/openai/deployments/text-embedding-3-large/embeddings?api-version=2024-10-21",
-      "apiKeyHeaderName": "api-key", "apiKeyPrefix": "", "apiKey": "...",
+      "apiKeyHeaderName": "api-key", "apiKeyPrefix": "", "apiKey": 
"${env:AZURE_OPENAI_KEY}",
       "requestParameters": { "dimensions": 1024 } } }
   }
 }
@@ -128,10 +128,10 @@ asynchronous, object-store-backed jobs that an embeddings 
call cannot use.
 {
   "engines": {
     "gemini": { "openai-embedding-engine": {
-      "baseUrl": "http://litellm:4000";, "apiKey": "...",
+      "baseUrl": "http://litellm:4000";, "apiKey": "${env:LITELLM_KEY}",
       "model": "gemini-embedding-2-preview", "imageInput": "data-uri", 
"maxBatchSize": 6 } },
     "nova": { "openai-embedding-engine": {
-      "baseUrl": "http://litellm:4000";, "apiKey": "...",
+      "baseUrl": "http://litellm:4000";, "apiKey": "${env:LITELLM_KEY}",
       "model": "bedrock/amazon.nova-2-multimodal-embeddings-v1:0",
       "imageInput": "data-uri", "maxBatchSize": 1,
       "requestParameters": { "dimensions": 1024, "embedding_purpose": 
"GENERIC_INDEX" } } }
@@ -323,7 +323,7 @@ as mono Opus). Two bindings on one engine index both 
channels of a video:
 ----
 {
   "engines": {
-    "jina": { "openai-embedding-engine": { "baseUrl": "https://api.jina.ai";, 
"apiKey": "...",
+    "jina": { "openai-embedding-engine": { "baseUrl": "https://api.jina.ai";, 
"apiKey": "${env:JINA_API_KEY}",
               "model": "jina-embeddings-v5-omni-small", "mediaInput": "object",
               "requestParameters": { "task": "retrieval.passage", 
"dimensions": 1024 } } }
   },
@@ -401,7 +401,8 @@ There is no Jina engine class; it is the OpenAI shape with 
Jina's keys:
 |`https://api.jina.ai` (the engine appends `/v1/embeddings`).
 
 |`apiKey`
-|The Jina key; sent as `Authorization: Bearer`.
+|The Jina key, sent as `Authorization: Bearer`; `${env:JINA_API_KEY}` keeps it 
out of the file
+(see xref:configuration/index.adoc#env[Secrets from the environment]).
 
 |`model`
 |`jina-embeddings-v5-omni-small` or `jina-embeddings-v5-omni-nano`.
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index 567e636207..b9c394a510 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -198,7 +198,8 @@ All three parsers share the same configuration POJO 
(`VLMOCRConfig`):
 |`apiKey`
 |`""` (empty)
 |API key. Format depends on the parser (Bearer header, x-api-key header,
-or query parameter).
+or query parameter). `${env:NAME}` reads it from the environment
+(xref:configuration/index.adoc#env[Secrets from the environment]).
 
 |`inlineContent`
 |`true`
diff --git a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc 
b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
index 340456b036..a44673d0a4 100644
--- a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
+++ b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
@@ -210,7 +210,7 @@ xref:configuration/index.adoc#tika-extras-dir[Adding extra 
jars].
 ----
 {
   "engines": {
-    "jina": { "jina-ocr-parser": { "endpoint": "http://jina:9000";, "apiKey": 
"..." } }
+    "jina": { "jina-ocr-parser": { "endpoint": "http://jina:9000";, "apiKey": 
"${env:JINA_API_KEY}" } }
   },
   "text-recognizers": [
     { "engine": "jina" }
diff --git 
a/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc
index 6bd4ccc521..8a52e837b4 100644
--- 
a/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc
+++ 
b/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc
@@ -101,7 +101,7 @@ A full pipeline, with the handler type stated explicitly 
and chunks emitted to E
       "openai-embedding-filter": {
         "baseUrl": "https://api.openai.com";,
         "model": "text-embedding-3-small",
-        "apiKey": "${OPENAI_API_KEY}",
+        "apiKey": "${env:OPENAI_API_KEY}",
         "maxChunkChars": 1500,
         "overlapChars": 200
       }
diff --git a/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc
index 1b63064fb8..904bbfd244 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc
@@ -143,7 +143,8 @@ include::example$pipes-elasticsearch-emitter.json[]
 
 |`apiKey`
 |_none_
-|Base64-encoded `id:api_key`. See <<credentials>>.
+|Base64-encoded `id:api_key`. See <<credentials>>. `${env:ES_API_KEY}` reads 
it from the
+environment (xref:configuration/index.adoc#env[Secrets from the environment]).
 
 |`httpClientConfig`
 |_none_
diff --git a/docs/modules/ROOT/pages/pipes/plugins/http.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/http.adoc
index 9ce1dd34a5..fea9d0e2a7 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/http.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/http.adoc
@@ -49,6 +49,7 @@ include::example$pipes-http-fetcher.json[]
 |`userName` / `password`
 |_none_
 |Credentials. Applied only when `authScheme` is also set — without it they are 
silently ignored.
+`${env:NAME}` reads one from the environment 
(xref:configuration/index.adoc#env[Secrets from the environment]).
 
 |`ntDomain`
 |_none_
diff --git a/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc
index a57f45b48a..fd762c480f 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc
@@ -51,7 +51,8 @@ client by either component, so setting them has no effect:
 
 |`userName` / `password`
 |_none_
-|Basic-auth credentials. Omit both for an anonymous client.
+|Basic-auth credentials. Omit both for an anonymous client. 
`${env:OPENSEARCH_PASSWORD}` reads
+the password from the environment (xref:configuration/index.adoc#env[Secrets 
from the environment]).
 
 |`verifySsl`
 |`true`
diff --git a/docs/modules/ROOT/pages/pipes/plugins/s3.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/s3.adoc
index 468844ea00..2721686204 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/s3.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/s3.adoc
@@ -83,7 +83,8 @@ include::example$pipes-s3-fetcher.json[]
 
 |`profile` / `accessKey` / `secretKey`
 |_conditional_
-|Required by the matching `credentialsProvider`.
+|Required by the matching `credentialsProvider`. 
`${env:AWS_SECRET_ACCESS_KEY}` keeps the
+secret out of the file (xref:configuration/index.adoc#env[Secrets from the 
environment]).
 
 |`spoolToTemp`
 |`true`
diff --git a/docs/modules/ROOT/pages/using-tika/server/tls.adoc 
b/docs/modules/ROOT/pages/using-tika/server/tls.adoc
index 56619f917b..0fbcf7e6af 100644
--- a/docs/modules/ROOT/pages/using-tika/server/tls.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/tls.adoc
@@ -56,7 +56,7 @@ Server authenticates to clients; clients present no 
certificate.
       "active": true,
       "keyStoreType": "PKCS12",
       "keyStoreFile": "/path/to/server-keystore.p12",
-      "keyStorePassword": "your-password",
+      "keyStorePassword": "${env:KEYSTORE_PASSWORD}",
       "clientAuthenticationWanted": false,
       "clientAuthenticationRequired": false
     }
@@ -78,10 +78,10 @@ Add a truststore holding the client certificates you trust, 
and require client a
       "active": true,
       "keyStoreType": "PKCS12",
       "keyStoreFile": "/path/to/server-keystore.p12",
-      "keyStorePassword": "your-password",
+      "keyStorePassword": "${env:KEYSTORE_PASSWORD}",
       "trustStoreType": "PKCS12",
       "trustStoreFile": "/path/to/server-truststore.p12",
-      "trustStorePassword": "your-password",
+      "trustStorePassword": "${env:TRUSTSTORE_PASSWORD}",
       "clientAuthenticationWanted": true,
       "clientAuthenticationRequired": true
     }
@@ -115,7 +115,8 @@ All properties below go in `server.tlsConfig`.
 |`keyStorePassword`
 |string
 |null
-|Password for the keystore.
+|Password for the keystore; `${env:KEYSTORE_PASSWORD}` reads it from the 
environment
+(xref:configuration/index.adoc#env[Secrets from the environment]).
 
 |`trustStoreType`
 |string
@@ -304,10 +305,10 @@ With `/tika/config/tika-config.json` holding, for example:
       "active": true,
       "keyStoreType": "PKCS12",
       "keyStoreFile": "/tika/certs/server-keystore.p12",
-      "keyStorePassword": "your-password",
+      "keyStorePassword": "${env:KEYSTORE_PASSWORD}",
       "trustStoreType": "PKCS12",
       "trustStoreFile": "/tika/certs/server-truststore.p12",
-      "trustStorePassword": "your-password",
+      "trustStorePassword": "${env:TRUSTSTORE_PASSWORD}",
       "clientAuthenticationRequired": true
     }
   }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/EnvInterpolator.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EnvInterpolator.java
new file mode 100644
index 0000000000..71cedb4870
--- /dev/null
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EnvInterpolator.java
@@ -0,0 +1,98 @@
+/*
+ * 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.config.loader;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+
+import org.apache.tika.exception.TikaConfigException;
+
+/**
+ * Resolves {@code ${env:NAME}} inside the string values of a startup config 
to the
+ * environment variable's value, in place. Only that exact form is touched: 
{@code ${NAME}},
+ * {@code $NAME} and everything else stay literal. A reference to an unset 
variable fails the
+ * load, naming the variable and the JSON path but never a value. Applied by
+ * {@link TikaJsonConfig#load} only, never to JSON a request supplies.
+ *
+ * @since Apache Tika 4.1
+ */
+final class EnvInterpolator {
+
+    static final Pattern REFERENCE = 
Pattern.compile("\\$\\{env:([A-Za-z_][A-Za-z0-9_]*)}");
+
+    private EnvInterpolator() {
+    }
+
+    static void resolve(JsonNode root, Function<String, String> env) throws 
TikaConfigException {
+        resolve(root, env, "");
+    }
+
+    private static void resolve(JsonNode node, Function<String, String> env, 
String path)
+            throws TikaConfigException {
+        if (node instanceof ObjectNode) {
+            ObjectNode object = (ObjectNode) node;
+            Iterator<Map.Entry<String, JsonNode>> fields = object.fields();
+            while (fields.hasNext()) {
+                Map.Entry<String, JsonNode> field = fields.next();
+                String childPath = path + "/" + field.getKey();
+                if (field.getValue().isTextual()) {
+                    field.setValue(resolveText(field.getValue().textValue(), 
env, childPath));
+                } else {
+                    resolve(field.getValue(), env, childPath);
+                }
+            }
+        } else if (node instanceof ArrayNode) {
+            ArrayNode array = (ArrayNode) node;
+            for (int i = 0; i < array.size(); i++) {
+                String childPath = path + "/" + i;
+                if (array.get(i).isTextual()) {
+                    array.set(i, resolveText(array.get(i).textValue(), env, 
childPath));
+                } else {
+                    resolve(array.get(i), env, childPath);
+                }
+            }
+        }
+    }
+
+    private static TextNode resolveText(String text, Function<String, String> 
env, String path)
+            throws TikaConfigException {
+        Matcher m = REFERENCE.matcher(text);
+        if (!m.find()) {
+            return TextNode.valueOf(text);
+        }
+        StringBuilder sb = new StringBuilder();
+        do {
+            String name = m.group(1);
+            String value = env.apply(name);
+            if (value == null) {
+                throw new TikaConfigException("environment variable " + name
+                        + " is not set; referenced at " + path);
+            }
+            m.appendReplacement(sb, Matcher.quoteReplacement(value));
+        } while (m.find());
+        m.appendTail(sb);
+        return TextNode.valueOf(sb.toString());
+    }
+}
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
index eae5a0555c..7c363054a6 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
@@ -161,7 +161,9 @@ public class TikaJsonConfig {
     }
 
     /**
-     * Loads configuration from an input stream.
+     * Loads configuration from an input stream. A string value may reference 
an environment
+     * variable as {@code ${env:NAME}}, alone or inside a longer string; an 
unset one fails
+     * the load. No other form is interpolated.
      *
      * @param inputStream the input stream containing JSON configuration
      * @return the parsed configuration
@@ -170,6 +172,8 @@ public class TikaJsonConfig {
     public static TikaJsonConfig load(InputStream inputStream) throws 
TikaConfigException {
         try {
             JsonNode rootNode = OBJECT_MAPPER.readTree(inputStream);
+            // startup config only: a request's JSON never reaches this method
+            EnvInterpolator.resolve(rootNode, System::getenv);
             TikaJsonConfig tikaJsonConfig = new TikaJsonConfig(rootNode);
             tikaJsonConfig.validateKeys();
             return tikaJsonConfig;
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/EnvInterpolatorTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EnvInterpolatorTest.java
new file mode 100644
index 0000000000..034486fca3
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EnvInterpolatorTest.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+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.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.exception.TikaConfigException;
+
+public class EnvInterpolatorTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private static JsonNode resolve(String json, Map<String, String> env) 
throws Exception {
+        JsonNode root = MAPPER.readTree(json);
+        EnvInterpolator.resolve(root, env::get);
+        return root;
+    }
+
+    @Test
+    public void testWholeValueAndEmbedded() throws Exception {
+        JsonNode root = resolve("""
+                { "engines": { "jina": { "openai-embedding-engine": {
+                    "apiKey": "${env:JINA_API_KEY}",
+                    "baseUrl": "https://${env:JINA_HOST}/v1";,
+                    "requestParameters": { "task": "retrieval.passage" } } } },
+                  "parsers": [ "html-parser", { "pdf-parser": { "ocr": { 
"language": "${env:LANG_A}+${env:LANG_B}" } } } ] }
+                """, Map.of("JINA_API_KEY", "s3cret", "JINA_HOST", 
"api.jina.ai",
+                "LANG_A", "eng", "LANG_B", "deu"));
+        JsonNode engine = root.at("/engines/jina/openai-embedding-engine");
+        assertEquals("s3cret", engine.get("apiKey").asText());
+        assertEquals("https://api.jina.ai/v1";, engine.get("baseUrl").asText());
+        assertEquals("retrieval.passage", 
engine.at("/requestParameters/task").asText(),
+                "untouched strings stay");
+        assertEquals("eng+deu", 
root.at("/parsers/1/pdf-parser/ocr/language").asText(),
+                "resolved inside arrays too");
+        assertEquals("html-parser", root.at("/parsers/0").asText());
+    }
+
+    @Test
+    public void testOnlyTheEnvFormIsTouched() throws Exception {
+        JsonNode root = resolve("""
+                { "engines": { "vlm": { "openai-vlm-parser": {
+                    "prompt": "Return ${fields} as $JSON and ${env:} and 
${sys:HOME} and $${env:X}" } } } }
+                """, Map.of("X", "no", "fields", "no", "HOME", "no"));
+        assertEquals("Return ${fields} as $JSON and ${env:} and ${sys:HOME} 
and $no",
+                root.at("/engines/vlm/openai-vlm-parser/prompt").asText());
+    }
+
+    @Test
+    public void testUnsetFailsNamingTheVariableAndPathNotAValue() throws 
Exception {
+        TikaConfigException e = assertThrows(TikaConfigException.class, () -> 
resolve("""
+                { "emitters": { "es": { "es-emitter": { "apiKey": 
"${env:ES_KEY}",
+                    "esUrl": "http://x"; } } } }
+                """, Map.of("OTHER", "value")));
+        assertTrue(e.getMessage().contains("ES_KEY"), e.getMessage());
+        assertTrue(e.getMessage().contains("/emitters/es/es-emitter/apiKey"), 
e.getMessage());
+        assertFalse(e.getMessage().contains("value"), e.getMessage());
+    }
+
+    @Test
+    public void testNonStringsUntouched() throws Exception {
+        JsonNode root = resolve("""
+                { "pipes": { "numClients": 4, "forkedJvmArgs": 
["-Xmx${env:HEAP}"], "on": true, "n": null } }
+                """, Map.of("HEAP", "2g"));
+        assertEquals(4, root.at("/pipes/numClients").asInt());
+        assertEquals("-Xmx2g", root.at("/pipes/forkedJvmArgs/0").asText());
+        assertTrue(root.at("/pipes/on").asBoolean());
+        assertTrue(root.at("/pipes/n").isNull());
+    }
+
+    /** The real entry point resolves against the process environment. */
+    @Test
+    public void testLoadResolvesFromTheProcessEnvironment() throws Exception {
+        String var = System.getenv().containsKey("PATH") ? "PATH" : "HOME";
+        String json = "{ \"pipes\": { \"tempDirectory\": \"${env:" + var + 
"}\" } }";
+        TikaJsonConfig config = TikaJsonConfig.load(
+                new 
ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)));
+        assertEquals(System.getenv(var), 
config.getRootNode().at("/pipes/tempDirectory").asText());
+        assertThrows(TikaConfigException.class, () -> TikaJsonConfig.load(new 
ByteArrayInputStream(
+                "{ \"pipes\": { \"tempDirectory\": 
\"${env:TIKA_SURELY_UNSET_VARIABLE_4909}\" } }"
+                        .getBytes(StandardCharsets.UTF_8))));
+    }
+}
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PerRequestConfigNotInterpolatedTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PerRequestConfigNotInterpolatedTest.java
new file mode 100644
index 0000000000..1ab3456da6
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PerRequestConfigNotInterpolatedTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.core;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.ext.multipart.Attachment;
+import org.apache.cxf.jaxrs.ext.multipart.ContentDisposition;
+import org.apache.cxf.jaxrs.ext.multipart.MultipartBody;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.server.core.resource.RecursiveMetadataResource;
+import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter;
+
+/**
+ * {@code ${env:NAME}} is resolved in the startup config only (TIKA-4909). A 
request's config
+ * part must reach the parse as the literal string: a caller must not be able 
to read the
+ * server's environment through it.
+ */
+public class PerRequestConfigNotInterpolatedTest extends CXFTestBase {
+
+    private static final String TEST_DOC = 
"test-documents/mock/hello_world.xml";
+
+    @Override
+    protected boolean isAllowPerRequestConfig() {
+        return true;
+    }
+
+    @Override
+    protected void setUpResources(JAXRSServerFactoryBean sf) {
+        sf.setResourceClasses(RecursiveMetadataResource.class);
+        sf.setResourceProvider(RecursiveMetadataResource.class,
+                new SingletonResourceProvider(new 
RecursiveMetadataResource(tikaResource)));
+    }
+
+    @Override
+    protected void setUpProviders(JAXRSServerFactoryBean sf) {
+        List<Object> providers = new ArrayList<>();
+        providers.add(new MetadataListMessageBodyWriter());
+        providers.add(new TikaServerParseExceptionMapper());
+        providers.add(new BadRequestExceptionMapper());
+        sf.setProviders(providers);
+    }
+
+    @Test
+    public void testRequestConfigKeepsEnvReferenceLiteral() throws Exception {
+        String var = System.getenv().containsKey("PATH") ? "PATH" : "HOME";
+        // renames Content-Type to whatever the value resolves to: the 
literal, if not interpolated
+        String config = "{\"metadata-filters\": [ { 
\"field-name-mapping-filter\": {"
+                + "\"mappings\": { \"Content-Type\": \"${env:" + var + "}\" },"
+                + "\"excludeUnmapped\": false } } ] }";
+        ContentDisposition cd =
+                new ContentDisposition("form-data; name=\"file\"; 
filename=\"hello_world.xml\"");
+        List<Attachment> atts = new ArrayList<>();
+        atts.add(new Attachment("file", 
ClassLoader.getSystemResourceAsStream(TEST_DOC), cd));
+        atts.add(new Attachment("config", "application/json",
+                new ByteArrayInputStream(config.getBytes(UTF_8))));
+        Response response = WebClient.create(endPoint + "/rmeta/config")
+                .type("multipart/form-data").accept("application/json")
+                .post(new MultipartBody(atts));
+        assertEquals(200, response.getStatus());
+        Reader reader = new InputStreamReader((InputStream) 
response.getEntity(), UTF_8);
+        Metadata m = JsonMetadataList.fromJson(reader).get(0);
+        assertNotNull(m.get("${env:" + var + "}"), "the literal name is the 
mapped field");
+        assertNull(m.get(System.getenv(var)), "the environment value never 
became a field name");
+        assertNull(m.get("Content-Type"), "the mapping did apply");
+    }
+}

Reply via email to