ParseContextDeserializer.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.serialization.serdes;

import static org.apache.tika.serialization.serdes.ParseContextSerializer.PARSE_CONTEXT;

import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.tika.config.loader.ComponentInfo;
import org.apache.tika.config.loader.TikaObjectMapperFactory;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.serialization.ComponentNameResolver;

/**
 * Deserializes ParseContext from JSON. Every entry is stored as a JSON config and resolved lazily
 * by {@link org.apache.tika.serialization.ParseContextUtils#resolveAll}; nothing is constructed
 * here. Example: {@code {"basic-content-handler-factory":{"type":"XML"},"metadata-filters":[...]}}
 */
public class ParseContextDeserializer extends JsonDeserializer<ParseContext> {

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

    /**
     * When true, refuses context-key types blocked from untrusted wire input
     * (see {@link ComponentNameResolver#isWireBlocked(Class)}). Use for request bodies (e.g.
     * tika-server /pipes); the default (false) is for trusted load-time config via TikaLoader.
     */
    private final boolean restricted;

    public ParseContextDeserializer() {
        this(false);
    }

    public ParseContextDeserializer(boolean restricted) {
        this.restricted = restricted;
    }

    private static ObjectMapper plainMapper() {
        return TikaObjectMapperFactory.getPlainMapper();
    }

    @Override
    public ParseContext deserialize(JsonParser jsonParser, DeserializationContext ctxt)
            throws IOException {
        JsonNode root = jsonParser.readValueAsTree();
        return readParseContext(root, restricted);
    }

    /**
     * Deserializes a ParseContext from a JsonNode. Every field is stored as a JSON config string
     * for lazy resolution; nothing is constructed here. Throws if multiple entries resolve to the
     * same context key (e.g. "bouncy-castle-digester" and "commons-digester" -> DigesterFactory).
     */
    public static ParseContext readParseContext(JsonNode jsonNode)
            throws IOException {
        return readParseContext(jsonNode, false);
    }

    /**
     * As {@link #readParseContext(JsonNode)}, but when {@code restricted} is true, refuses any
     * reference to a context-key type that is blocked from untrusted wire input
     * (see {@link ComponentNameResolver#isWireBlocked(Class)}).
     */
    public static ParseContext readParseContext(JsonNode jsonNode, boolean restricted)
            throws IOException {
        // Handle optional wrapper: { "parse-context": {...} }
        JsonNode contextNode = jsonNode.get(PARSE_CONTEXT);
        if (contextNode == null) {
            contextNode = jsonNode;
        }

        ParseContext parseContext = new ParseContext();

        if (!contextNode.isObject()) {
            return parseContext;
        }

        // Untrusted wire input: refuse any blocked component anywhere in the tree before anything
        // is constructed.
        if (restricted) {
            assertNoBlockedComponents(contextNode);
        }

        // contextKey -> friendlyName, to detect duplicates within this document.
        Map<Class<?>, String> seenContextKeys = new HashMap<>();

        Iterator<String> fieldNames = contextNode.fieldNames();
        while (fieldNames.hasNext()) {
            String name = fieldNames.next();
            JsonNode value = contextNode.get(name);
            checkForDuplicateContextKey(name, seenContextKeys);
            // Store as a lazy JSON config (plain mapper: the main one may be binary/Smile).
            parseContext.setJsonConfig(name, plainMapper().writeValueAsString(value));
        }

        return parseContext;
    }

    /**
     * Checks if a JSON config entry would create a duplicate context key.
     * <p>
     * Looks up the friendly name in the component registry to determine its context key,
     * then checks if that key has already been seen in this document.
     *
     * @param friendlyName the friendly name of the config entry
     * @param seenContextKeys map of already-seen context keys to their friendly names
     * @throws IOException if a duplicate context key is detected
     */
    private static void checkForDuplicateContextKey(String friendlyName,
                                                     Map<Class<?>, String> seenContextKeys)
            throws IOException {
        Optional<ComponentInfo> infoOpt = ComponentNameResolver.getComponentInfo(friendlyName);
        if (infoOpt.isEmpty()) {
            // Not a registered component - can't check for duplicates, that's okay
            return;
        }

        ComponentInfo info = infoOpt.get();

        // Self-configuring components (e.g., parsers) stay as JSON configs and are
        // accessed by string key at runtime via ParseContextConfig.getConfig().
        // They never get resolved to typed objects in the context map, so multiple
        // self-configuring components with the same context key are not duplicates.
        if (info.selfConfiguring()) {
            return;
        }

        Class<?> contextKey = ComponentNameResolver.determineContextKey(info);

        String existingName = seenContextKeys.get(contextKey);
        if (existingName != null) {
            throw new IOException("Duplicate parse-context entries resolve to the same key " +
                    contextKey.getName() + ": '" + existingName + "' and '" + friendlyName + "'");
        }
        seenContextKeys.put(contextKey, friendlyName);
    }

    /**
     * Refuses any wire-blocked component a request must not be able to bind. Deserialization is
     * lazy, so this gate runs before {@code resolveAll} would instantiate anything. It is
     * position-aware: a top-level flat key for a self-configuring component (e.g.
     * {@code {"pdf-parser":{...}}}) is an inert per-request config that resolveAll skips, so it is
     * allowed and its opaque config subtree is not scanned; every other key is checked and its
     * subtree scanned, refusing any blocked object key at any depth. Bare-string references are not
     * scanned -- they name a defaults instance with no config. Running before resolution matters
     * because merely constructing a blocked component can have side effects.
     */
    private static void assertNoBlockedComponents(JsonNode contextNode) throws IOException {
        if (contextNode == null || !contextNode.isObject()) {
            return;
        }
        Iterator<Map.Entry<String, JsonNode>> fields = contextNode.fields();
        while (fields.hasNext()) {
            Map.Entry<String, JsonNode> e = fields.next();
            String name = e.getKey();
            if (isSelfConfiguring(name)) {
                // Inert per-request config for a self-configuring component; resolveAll skips it.
                continue;
            }
            assertNameAllowed(name);
            scanForBlocked(e.getValue());
        }
    }

    /**
     * Recursively refuses blocked-component object keys within a scanned subtree. Only object keys
     * are checked; bare strings are inert defaults (see {@link #assertNoBlockedComponents}).
     */
    private static void scanForBlocked(JsonNode node) throws IOException {
        if (node == null) {
            return;
        }
        if (node.isObject()) {
            Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
            while (fields.hasNext()) {
                Map.Entry<String, JsonNode> e = fields.next();
                assertNameAllowed(e.getKey());
                scanForBlocked(e.getValue());
            }
        } else if (node.isArray()) {
            for (JsonNode child : node) {
                scanForBlocked(child);
            }
        }
    }

    private static boolean isSelfConfiguring(String name) {
        Optional<ComponentInfo> infoOpt = ComponentNameResolver.getComponentInfo(name);
        return infoOpt.isPresent() && infoOpt.get().selfConfiguring();
    }

    private static void assertNameAllowed(String name) throws IOException {
        Optional<ComponentInfo> infoOpt = ComponentNameResolver.getComponentInfo(name);
        if (infoOpt.isEmpty()) {
            return;
        }
        Class<?> contextKey = ComponentNameResolver.determineContextKey(infoOpt.get());
        if (ComponentNameResolver.isWireBlocked(contextKey)) {
            throw new IOException(wireBlockedMessage(name, contextKey));
        }
    }

    private static String wireBlockedMessage(String friendlyName, Class<?> contextKey) {
        return "Component '" + friendlyName + "' (context key " + contextKey.getName() +
                ") may not be supplied via a request parseContext. Components of this type must " +
                "be configured server-side in the Tika config, not at request time.";
    }
}