TikaExtras.java

/*
 * 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;

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Opt-in mechanism for adding user-supplied "extras" jars (extra
 * {@code EncodingDetector}s, {@code Parser}s, etc.) to Tika's SPI discovery
 * without repackaging the application.
 *
 * <p><b>Off by default.</b> Nothing is loaded unless the
 * {@value #EXTRAS_DIR_PROPERTY} system property points at a directory; then every
 * {@code *.jar} in it is made visible to service-loading.  There is no implicit or
 * default directory ��� the feature is off unless the property is set.  (A relative
 * property value is resolved against the process working directory, like any path.)
 *
 * <p><b>Security:</b> this is a trusted code directory ��� anything in it runs with
 * the full privileges of the Tika process.  Treat write access to it exactly like
 * write access to {@code lib/}; it must not be writable by less-trusted principals
 * (for servers, not reachable by request handling).  Being opt-in keeps "we are
 * now loading extra code" an explicit, auditable choice.
 */
public final class TikaExtras {

    /** System property naming the extras directory.  Unset = feature off. */
    public static final String EXTRAS_DIR_PROPERTY = "tika.extras.dir";

    private static final Logger LOG = LoggerFactory.getLogger(TikaExtras.class);

    private TikaExtras() {
    }

    /**
     * If {@value #EXTRAS_DIR_PROPERTY} is set, installs a classloader over the
     * {@code *.jar} files in that directory as the thread + Tika
     * {@link ServiceLoader} context classloader, so they join SPI discovery.
     * No-op (returns {@code null}) when the property is unset or the directory is
     * missing/empty.  Call exactly once at startup, before any Tika component is
     * loaded: each call builds a new classloader, so repeated calls stack them and
     * leave the earlier ones' open jar handles dangling.
     *
     * @return the installed classloader, or {@code null} if extras are off/empty
     */
    public static ClassLoader install() {
        List<Path> jars = extraJars();
        if (jars.isEmpty()) {
            return null;
        }
        List<URL> urls = new ArrayList<>(jars.size());
        List<Path> loaded = new ArrayList<>(jars.size());
        for (Path jar : jars) {
            try {
                urls.add(jar.toUri().toURL());
                loaded.add(jar);
            } catch (Exception e) {
                LOG.warn("Skipping extra jar {}: {}", jar, e.toString());
            }
        }
        if (urls.isEmpty()) {
            return null;
        }
        ClassLoader parent = Thread.currentThread().getContextClassLoader();
        if (parent == null) {
            parent = TikaExtras.class.getClassLoader();
        }
        URLClassLoader cl = new URLClassLoader(urls.toArray(new URL[0]), parent);
        Thread.currentThread().setContextClassLoader(cl);
        ServiceLoader.setContextClassLoader(cl);
        LOG.info("{}: loaded {} extra jar(s): {}", EXTRAS_DIR_PROPERTY, loaded.size(), loaded);
        return cl;
    }

    /**
     * The {@code *.jar} files in the {@value #EXTRAS_DIR_PROPERTY} directory ��� for
     * callers that extend a forked process's classpath rather than installing a
     * classloader.  Empty when the property is unset or the directory is
     * missing/has no jars.
     */
    public static List<Path> extraJars() {
        Path dir = extrasDir();
        if (dir == null || !Files.isDirectory(dir)) {
            return Collections.emptyList();
        }
        List<Path> jars = new ArrayList<>();
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.jar")) {
            for (Path jar : stream) {
                jars.add(jar);
            }
        } catch (Exception e) {
            LOG.warn("Could not scan {}={}: {}", EXTRAS_DIR_PROPERTY, dir, e.toString());
        }
        // Sort by file name so jar load order (classloader URL order / forked-child
        // classpath order, hence SPI precedence) is deterministic across platforms
        // and filesystems rather than depending on directory iteration order.
        jars.sort(Comparator.comparing(jar -> jar.getFileName().toString()));
        return jars;
    }

    /**
     * Appends the {@link #extraJars()} (as absolute paths, joined with the
     * platform path separator) to the given classpath string ��� for extending a
     * forked process's {@code -cp} with the extras jars.  Returns {@code classpath}
     * unchanged when the feature is off or the directory has no jars.
     *
     * @param classpath the base classpath to extend
     * @return the classpath with any extras jars appended
     */
    public static String appendJarsToClasspath(String classpath) {
        List<Path> jars = extraJars();
        if (jars.isEmpty()) {
            return classpath;
        }
        String separator = File.pathSeparator;
        StringBuilder sb = new StringBuilder();
        if (classpath != null && !classpath.isEmpty()) {
            sb.append(classpath);
        }
        for (Path jar : jars) {
            if (sb.length() > 0) {
                sb.append(separator);
            }
            sb.append(jar.toAbsolutePath());
        }
        return sb.toString();
    }

    /** The configured extras directory, or {@code null} if the feature is off. */
    public static Path extrasDir() {
        String prop = System.getProperty(EXTRAS_DIR_PROPERTY);
        if (prop == null || prop.isBlank()) {
            return null;
        }
        try {
            return Path.of(prop.trim());
        } catch (java.nio.file.InvalidPathException e) {
            LOG.warn("Ignoring invalid {}: {}", EXTRAS_DIR_PROPERTY, e.getMessage());
            return null;
        }
    }
}