Skip to content

Commit 8870bf9

Browse files
authored
TIKA-4755 - extra jars (#2880)
1 parent 2ce13d8 commit 8870bf9

9 files changed

Lines changed: 362 additions & 2 deletions

File tree

CHANGES.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ Release 4.0.0-beta-1 - unreleased
99
-x/--xml, the server /tika/xml and /rmeta/xml paths (or the
1010
X-Tika-Handler header), and the async CLI --handler x (TIKA-4663).
1111

12+
NEW FEATURES
13+
14+
* tika-app and tika-server can load extra jars (additional EncodingDetectors,
15+
Parsers, etc.) from the directory named by the -Dtika.extras.dir system
16+
property, without repackaging the application. Off by default; the directory
17+
is a trusted code location whose contents run with full process privileges.
18+
The extra jars are also forwarded onto forked pipes/server worker processes,
19+
so they are available where parsing actually happens (TIKA-4755).
20+
1221

1322
Release 4.0.0-alpha-1 - 5/4/2026
1423

docs/modules/ROOT/pages/configuration/index.adoc

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,34 @@ content handlers, server behavior, and the Tika Pipes pipeline.
2828
NOTE: Tika 3.x and earlier used XML configuration (`tika-config.xml`). See the
2929
xref:migration-to-4x/index.adoc[Migration Guide] for details on converting to JSON.
3030

31+
== Adding extra jars (`tika.extras.dir`)
32+
33+
To add extra components — additional `EncodingDetector` or `Parser`
34+
implementations, or their dependencies — without repackaging the application,
35+
drop their jars in a directory and point the `tika.extras.dir` system property
36+
at it:
37+
38+
[source,bash]
39+
----
40+
java -Dtika.extras.dir=/path/to/extras -jar tika-app.jar ...
41+
java -Dtika.extras.dir=/path/to/extras -jar tika-server-standard.jar ...
42+
----
43+
44+
Every `*.jar` in that directory is added to the classpath that Tika's
45+
service-loading scans, so SPI-registered components in those jars are picked up
46+
automatically. The jars are also forwarded onto forked Pipes/server worker
47+
processes, so they are available where parsing happens.
48+
49+
This is *off by default* — nothing is loaded unless `tika.extras.dir` is set, and
50+
there is no implicit default directory (Tika does not scan the working directory automatically). Treat the directory as a trusted code
51+
location: anything in it runs with the full privileges of the Tika process, so it
52+
must not be writable by less-trusted principals (and, for a server, must not be
53+
reachable by request handling).
54+
55+
NOTE: In the Docker images you can instead mount a directory to `/tika-extras`.
56+
PF4J-managed plugins are a separate mechanism — see
57+
xref:pipes/plugins/index.adoc[Pipes Plugins].
58+
3159
== Top-level JSON structure
3260

3361
A `tika-config.json` is a single JSON object whose keys are the top-level sections

tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import org.apache.tika.Tika;
7070
import org.apache.tika.async.cli.TikaAsyncCLI;
7171
import org.apache.tika.config.EmbeddedLimits;
72+
import org.apache.tika.config.TikaExtras;
7273
import org.apache.tika.config.TimeoutLimits;
7374
import org.apache.tika.config.loader.ComponentRegistry;
7475
import org.apache.tika.config.loader.TikaLoader;
@@ -256,6 +257,7 @@ public TikaCLI() {
256257
}
257258

258259
public static void main(String[] args) throws Exception {
260+
TikaExtras.install();
259261
TikaCLI cli = new TikaCLI();
260262

261263
if (cli.testForHelp(args)) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.config;
18+
19+
import java.io.File;
20+
import java.net.URL;
21+
import java.net.URLClassLoader;
22+
import java.nio.file.DirectoryStream;
23+
import java.nio.file.Files;
24+
import java.nio.file.Path;
25+
import java.util.ArrayList;
26+
import java.util.Collections;
27+
import java.util.Comparator;
28+
import java.util.List;
29+
30+
import org.slf4j.Logger;
31+
import org.slf4j.LoggerFactory;
32+
33+
/**
34+
* Opt-in mechanism for adding user-supplied "extras" jars (extra
35+
* {@code EncodingDetector}s, {@code Parser}s, etc.) to Tika's SPI discovery
36+
* without repackaging the application.
37+
*
38+
* <p><b>Off by default.</b> Nothing is loaded unless the
39+
* {@value #EXTRAS_DIR_PROPERTY} system property points at a directory; then every
40+
* {@code *.jar} in it is made visible to service-loading. There is no implicit or
41+
* default directory — the feature is off unless the property is set. (A relative
42+
* property value is resolved against the process working directory, like any path.)
43+
*
44+
* <p><b>Security:</b> this is a trusted code directory — anything in it runs with
45+
* the full privileges of the Tika process. Treat write access to it exactly like
46+
* write access to {@code lib/}; it must not be writable by less-trusted principals
47+
* (for servers, not reachable by request handling). Being opt-in keeps "we are
48+
* now loading extra code" an explicit, auditable choice.
49+
*/
50+
public final class TikaExtras {
51+
52+
/** System property naming the extras directory. Unset = feature off. */
53+
public static final String EXTRAS_DIR_PROPERTY = "tika.extras.dir";
54+
55+
private static final Logger LOG = LoggerFactory.getLogger(TikaExtras.class);
56+
57+
private TikaExtras() {
58+
}
59+
60+
/**
61+
* If {@value #EXTRAS_DIR_PROPERTY} is set, installs a classloader over the
62+
* {@code *.jar} files in that directory as the thread + Tika
63+
* {@link ServiceLoader} context classloader, so they join SPI discovery.
64+
* No-op (returns {@code null}) when the property is unset or the directory is
65+
* missing/empty. Call exactly once at startup, before any Tika component is
66+
* loaded: each call builds a new classloader, so repeated calls stack them and
67+
* leave the earlier ones' open jar handles dangling.
68+
*
69+
* @return the installed classloader, or {@code null} if extras are off/empty
70+
*/
71+
public static ClassLoader install() {
72+
List<Path> jars = extraJars();
73+
if (jars.isEmpty()) {
74+
return null;
75+
}
76+
List<URL> urls = new ArrayList<>(jars.size());
77+
List<Path> loaded = new ArrayList<>(jars.size());
78+
for (Path jar : jars) {
79+
try {
80+
urls.add(jar.toUri().toURL());
81+
loaded.add(jar);
82+
} catch (Exception e) {
83+
LOG.warn("Skipping extra jar {}: {}", jar, e.toString());
84+
}
85+
}
86+
if (urls.isEmpty()) {
87+
return null;
88+
}
89+
ClassLoader parent = Thread.currentThread().getContextClassLoader();
90+
if (parent == null) {
91+
parent = TikaExtras.class.getClassLoader();
92+
}
93+
URLClassLoader cl = new URLClassLoader(urls.toArray(new URL[0]), parent);
94+
Thread.currentThread().setContextClassLoader(cl);
95+
ServiceLoader.setContextClassLoader(cl);
96+
LOG.info("{}: loaded {} extra jar(s): {}", EXTRAS_DIR_PROPERTY, loaded.size(), loaded);
97+
return cl;
98+
}
99+
100+
/**
101+
* The {@code *.jar} files in the {@value #EXTRAS_DIR_PROPERTY} directory — for
102+
* callers that extend a forked process's classpath rather than installing a
103+
* classloader. Empty when the property is unset or the directory is
104+
* missing/has no jars.
105+
*/
106+
public static List<Path> extraJars() {
107+
Path dir = extrasDir();
108+
if (dir == null || !Files.isDirectory(dir)) {
109+
return Collections.emptyList();
110+
}
111+
List<Path> jars = new ArrayList<>();
112+
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.jar")) {
113+
for (Path jar : stream) {
114+
jars.add(jar);
115+
}
116+
} catch (Exception e) {
117+
LOG.warn("Could not scan {}={}: {}", EXTRAS_DIR_PROPERTY, dir, e.toString());
118+
}
119+
// Sort by file name so jar load order (classloader URL order / forked-child
120+
// classpath order, hence SPI precedence) is deterministic across platforms
121+
// and filesystems rather than depending on directory iteration order.
122+
jars.sort(Comparator.comparing(jar -> jar.getFileName().toString()));
123+
return jars;
124+
}
125+
126+
/**
127+
* Appends the {@link #extraJars()} (as absolute paths, joined with the
128+
* platform path separator) to the given classpath string — for extending a
129+
* forked process's {@code -cp} with the extras jars. Returns {@code classpath}
130+
* unchanged when the feature is off or the directory has no jars.
131+
*
132+
* @param classpath the base classpath to extend
133+
* @return the classpath with any extras jars appended
134+
*/
135+
public static String appendJarsToClasspath(String classpath) {
136+
List<Path> jars = extraJars();
137+
if (jars.isEmpty()) {
138+
return classpath;
139+
}
140+
String separator = File.pathSeparator;
141+
StringBuilder sb = new StringBuilder();
142+
if (classpath != null && !classpath.isEmpty()) {
143+
sb.append(classpath);
144+
}
145+
for (Path jar : jars) {
146+
if (sb.length() > 0) {
147+
sb.append(separator);
148+
}
149+
sb.append(jar.toAbsolutePath());
150+
}
151+
return sb.toString();
152+
}
153+
154+
/** The configured extras directory, or {@code null} if the feature is off. */
155+
public static Path extrasDir() {
156+
String prop = System.getProperty(EXTRAS_DIR_PROPERTY);
157+
if (prop == null || prop.isBlank()) {
158+
return null;
159+
}
160+
try {
161+
return Path.of(prop.trim());
162+
} catch (java.nio.file.InvalidPathException e) {
163+
LOG.warn("Ignoring invalid {}: {}", EXTRAS_DIR_PROPERTY, e.getMessage());
164+
return null;
165+
}
166+
}
167+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.config;
18+
19+
import static java.nio.charset.StandardCharsets.UTF_8;
20+
import static org.junit.jupiter.api.Assertions.assertEquals;
21+
import static org.junit.jupiter.api.Assertions.assertNotNull;
22+
import static org.junit.jupiter.api.Assertions.assertNull;
23+
import static org.junit.jupiter.api.Assertions.assertSame;
24+
import static org.junit.jupiter.api.Assertions.assertTrue;
25+
26+
import java.net.URLClassLoader;
27+
import java.nio.file.Files;
28+
import java.nio.file.Path;
29+
import java.util.jar.JarEntry;
30+
import java.util.jar.JarOutputStream;
31+
32+
import org.junit.jupiter.api.Test;
33+
import org.junit.jupiter.api.io.TempDir;
34+
35+
public class TikaExtrasTest {
36+
37+
private static final String MARKER = "tika-extra-marker.txt";
38+
39+
@Test
40+
public void offWhenPropertyUnset() throws Exception {
41+
withProperty(null, () -> {
42+
assertNull(TikaExtras.extrasDir(), "feature must be off with no property");
43+
assertTrue(TikaExtras.extraJars().isEmpty());
44+
assertNull(TikaExtras.install());
45+
});
46+
}
47+
48+
@Test
49+
public void emptyWhenDirMissing(@TempDir Path tmp) throws Exception {
50+
Path missing = tmp.resolve("does-not-exist");
51+
withProperty(missing.toString(), () -> {
52+
assertTrue(TikaExtras.extraJars().isEmpty());
53+
assertNull(TikaExtras.install());
54+
});
55+
}
56+
57+
@Test
58+
public void loadsJarsWhenPropertySet(@TempDir Path tmp) throws Exception {
59+
Path jar = tmp.resolve("extra.jar");
60+
try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jar))) {
61+
jos.putNextEntry(new JarEntry(MARKER));
62+
jos.write("hi".getBytes(UTF_8));
63+
jos.closeEntry();
64+
}
65+
ClassLoader prevCtx = Thread.currentThread().getContextClassLoader();
66+
ClassLoader[] installed = new ClassLoader[1];
67+
try {
68+
withProperty(tmp.toString(), () -> {
69+
assertEquals(1, TikaExtras.extraJars().size());
70+
ClassLoader cl = TikaExtras.install();
71+
installed[0] = cl;
72+
assertNotNull(cl, "extras dir with a jar should install a classloader");
73+
assertNotNull(cl.getResource(MARKER), "the extra jar must be on the classloader");
74+
assertSame(cl, Thread.currentThread().getContextClassLoader());
75+
});
76+
} finally {
77+
Thread.currentThread().setContextClassLoader(prevCtx);
78+
// ServiceLoader.CONTEXT_CLASS_LOADER defaults to null and this is the
79+
// only test that sets it; reset to null rather than to the thread's
80+
// context loader so we don't leak global state into later tests.
81+
ServiceLoader.setContextClassLoader(null);
82+
// Close the URLClassLoader so it releases its handle on extra.jar before
83+
// @TempDir cleanup runs; otherwise the delete fails on Windows, where an
84+
// open file cannot be removed.
85+
if (installed[0] instanceof URLClassLoader) {
86+
((URLClassLoader) installed[0]).close();
87+
}
88+
}
89+
}
90+
91+
@Test
92+
public void appendJarsToClasspathOffReturnsInput() throws Exception {
93+
withProperty(null, () -> {
94+
assertNull(TikaExtras.appendJarsToClasspath(null));
95+
assertEquals("base", TikaExtras.appendJarsToClasspath("base"));
96+
});
97+
}
98+
99+
@Test
100+
public void appendJarsToClasspathNoLeadingSeparator(@TempDir Path tmp) throws Exception {
101+
Path jar = tmp.resolve("extra.jar");
102+
try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jar))) {
103+
jos.putNextEntry(new JarEntry(MARKER));
104+
jos.closeEntry();
105+
}
106+
String sep = System.getProperty("path.separator");
107+
String abs = jar.toAbsolutePath().toString();
108+
withProperty(tmp.toString(), () -> {
109+
// null/empty base must not produce a leading separator
110+
assertEquals(abs, TikaExtras.appendJarsToClasspath(null));
111+
assertEquals(abs, TikaExtras.appendJarsToClasspath(""));
112+
// non-empty base gets the separator between it and the jar
113+
assertEquals("base" + sep + abs, TikaExtras.appendJarsToClasspath("base"));
114+
});
115+
}
116+
117+
private interface Body {
118+
void run() throws Exception;
119+
}
120+
121+
private static void withProperty(String value, Body body) throws Exception {
122+
String prev = System.getProperty(TikaExtras.EXTRAS_DIR_PROPERTY);
123+
try {
124+
if (value == null) {
125+
System.clearProperty(TikaExtras.EXTRAS_DIR_PROPERTY);
126+
} else {
127+
System.setProperty(TikaExtras.EXTRAS_DIR_PROPERTY, value);
128+
}
129+
body.run();
130+
} finally {
131+
if (prev == null) {
132+
System.clearProperty(TikaExtras.EXTRAS_DIR_PROPERTY);
133+
} else {
134+
System.setProperty(TikaExtras.EXTRAS_DIR_PROPERTY, prev);
135+
}
136+
}
137+
}
138+
}

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.slf4j.Logger;
3535
import org.slf4j.LoggerFactory;
3636

37+
import org.apache.tika.config.TikaExtras;
3738
import org.apache.tika.pipes.core.server.PipesServer;
3839
import org.apache.tika.utils.ProcessUtils;
3940

@@ -509,7 +510,8 @@ private String[] getCommandline() throws IOException {
509510

510511
private Path writeArgFile() throws IOException {
511512
Path argFile = tmpDir.resolve("jvm-args.txt");
512-
String classpath = System.getProperty("java.class.path");
513+
// forward any tika.extras.dir jars to the forked PipesServer
514+
String classpath = TikaExtras.appendJarsToClasspath(System.getProperty("java.class.path"));
513515
String normalizedClasspath = classpath.replace("\\", "/");
514516
String content = "-cp\n\"" + normalizedClasspath + "\"\n";
515517
Files.writeString(argFile, content, StandardCharsets.UTF_8);

0 commit comments

Comments
 (0)