MetadataInternalKeyGuardTest.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.metadata;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

/** Reserved Tika-native ({@code tk:}) keys can't be written by String writes -- only via Property; the String route throws. */
public class MetadataInternalKeyGuardTest {

    @Test
    public void testLegacyXTikaPrefixStaysReserved() {
        Metadata metadata = new Metadata();
        // pre-4.0.0 prefix stays reserved so a crafted file can't forge it during the 4.x window
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                () -> metadata.add(TikaCoreProperties.LEGACY_TIKA_META_PREFIX + "Parsed-By", "org.evil.FakeParser"),
                "legacy X-TIKA: String write must still throw");
        assertTrue(ex.getMessage().contains(TikaCoreProperties.LEGACY_TIKA_META_PREFIX + "Parsed-By"));
        assertNull(metadata.get(TikaCoreProperties.LEGACY_TIKA_META_PREFIX + "Parsed-By"));
    }

    @Test
    public void testStringWriteToInternalKeyThrows() {
        Metadata metadata = new Metadata();
        // hostile scrape
        assertThrows(IllegalArgumentException.class,
                () -> metadata.set(TikaCoreProperties.TIKA_CONTENT.getName(), "injected"),
                "String write to an internal key must throw");
        assertNull(metadata.get(TikaCoreProperties.TIKA_CONTENT));
        assertNull(metadata.get(TikaCoreProperties.TIKA_CONTENT.getName()));
    }

    @Test
    public void testStringAddToInternalMultiValueKeyThrows() {
        Metadata metadata = new Metadata();
        assertThrows(IllegalArgumentException.class,
                () -> metadata.add(TikaCoreProperties.TIKA_PARSED_BY.getName(), "org.evil.FakeParser"),
                "String add to an internal key must throw");
        assertArrayEquals(new String[0], metadata.getValues(TikaCoreProperties.TIKA_PARSED_BY));
    }

    @Test
    public void testStringWriteCannotOverwriteTrustedInternalValue() {
        Metadata metadata = new Metadata();
        metadata.set(TikaCoreProperties.TIKA_CONTENT, "trusted");
        // String-path attempt must throw, not clobber
        assertThrows(IllegalArgumentException.class,
                () -> metadata.set(TikaCoreProperties.TIKA_CONTENT.getName(), "injected"));
        assertEquals("trusted", metadata.get(TikaCoreProperties.TIKA_CONTENT));
    }

    @Test
    public void testReservedKeyThrowMessageNamesTheRemedies() {
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                () -> new Metadata().set("tk:content", "x"));
        // the message is user-facing migration guidance; pin the three remedies it names
        assertTrue(ex.getMessage().contains("putAll"));
        assertTrue(ex.getMessage().contains("KeyPrefix"));
        assertTrue(ex.getMessage().contains("setTrusted"));
    }

    @Test
    public void testPropertyWriteToInternalKeyStillWorks() {
        Metadata metadata = new Metadata();
        metadata.set(TikaCoreProperties.TIKA_CONTENT, "legit");
        assertEquals("legit", metadata.get(TikaCoreProperties.TIKA_CONTENT));

        metadata.add(TikaCoreProperties.TIKA_PARSED_BY, "p1");
        metadata.add(TikaCoreProperties.TIKA_PARSED_BY, "p2");
        assertArrayEquals(new String[] {"p1", "p2"},
                metadata.getValues(TikaCoreProperties.TIKA_PARSED_BY));
    }

    @Test
    public void testNonReservedStringKeysAreUnaffected() {
        Metadata metadata = new Metadata();
        // unregistered key passes through
        metadata.set("my:customKey", "value");
        assertEquals("value", metadata.get("my:customKey"));

        // a registered non-reserved property (dc:description) written by name still works
        metadata.set(TikaCoreProperties.DESCRIPTION.getName(), "hello");
        assertEquals("hello", metadata.get(TikaCoreProperties.DESCRIPTION));
    }

    @Test
    public void testNullNameDoesNotThrow() {
        Metadata metadata = new Metadata();
        // no NPE on a null name
        metadata.set((String) null, "x");
    }

    @Test
    public void testSetNullValueOnReservedKeyRemovesRatherThanThrows() {
        Metadata metadata = new Metadata();
        metadata.setTrusted(TikaCoreProperties.TIKA_CONTENT.getName(), "trusted");
        // set(name, null) is the documented removal path -- it must not be blocked by the
        // reserved-key guard, matching remove(name).
        metadata.set(TikaCoreProperties.TIKA_CONTENT.getName(), (String) null);
        assertNull(metadata.get(TikaCoreProperties.TIKA_CONTENT));
    }

    @Test
    public void testReconstructPreservesRegisteredReservedKey() {
        Metadata metadata = new Metadata();
        metadata.reconstruct(TikaCoreProperties.TIKA_CONTENT.getName(), "the content", false);
        assertEquals("the content", metadata.get(TikaCoreProperties.TIKA_CONTENT));

        metadata.reconstruct(TikaCoreProperties.TIKA_PARSED_BY.getName(), "p1", true);
        metadata.reconstruct(TikaCoreProperties.TIKA_PARSED_BY.getName(), "p2", true);
        assertArrayEquals(new String[] {"p1", "p2"},
                metadata.getValues(TikaCoreProperties.TIKA_PARSED_BY));
    }

    @Test
    public void testReconstructPreservesUnregisteredReservedKey() {
        Metadata metadata = new Metadata();
        String unregistered = TikaCoreProperties.TIKA_META_PREFIX + "noSuchRegisteredProperty";
        assertNull(Property.get(unregistered), "precondition: key must be unregistered");

        assertThrows(IllegalArgumentException.class, () -> metadata.set(unregistered, "thrown"));
        assertNull(metadata.get(unregistered));

        metadata.reconstruct(unregistered, "kept", false);
        assertEquals("kept", metadata.get(unregistered));

        metadata.reconstruct(unregistered, "kept2", true);
        assertArrayEquals(new String[] {"kept", "kept2"}, metadata.getValues(unregistered));
    }

    @Test
    public void testTrustedWriteBypassesGuard() {
        Metadata metadata = new Metadata();
        metadata.setTrusted(TikaCoreProperties.TIKA_CONTENT.getName(), "trusted");
        assertEquals("trusted", metadata.get(TikaCoreProperties.TIKA_CONTENT));

        // untrusted String-path attempt must throw, not clobber
        assertThrows(IllegalArgumentException.class,
                () -> metadata.set(TikaCoreProperties.TIKA_CONTENT.getName(), "blocked"));
        assertEquals("trusted", metadata.get(TikaCoreProperties.TIKA_CONTENT));
    }

    @Test
    public void testTrustedAddBypassesGuard() {
        Metadata metadata = new Metadata();
        metadata.addTrusted(TikaCoreProperties.TIKA_PARSED_BY.getName(), "p1");
        metadata.addTrusted(TikaCoreProperties.TIKA_PARSED_BY.getName(), "p2");
        assertArrayEquals(new String[] {"p1", "p2"},
                metadata.getValues(TikaCoreProperties.TIKA_PARSED_BY));
    }

    @Test
    public void testReconstructNonReservedRoutesThroughStringPath() {
        Metadata metadata = new Metadata();
        metadata.reconstruct("my:customKey", "v1", false);
        assertEquals("v1", metadata.get("my:customKey"));

        metadata.reconstruct("my:customKey", "v2", true);
        assertArrayEquals(new String[] {"v1", "v2"}, metadata.getValues("my:customKey"));
    }

    /**
     * {@code reconstruct} is a deliberately trusted route, not subject to the String-route
     * guard -- for both a registered curated Property and an unregistered reserved name,
     * contrasted directly against the throw on the same names via the String route.
     */
    @Test
    public void testReconstructIsNotSubjectToReservedKeyGuard() {
        Metadata metadata = new Metadata();

        // registered curated Property
        assertThrows(IllegalArgumentException.class,
                () -> metadata.set(TikaCoreProperties.TIKA_CONTENT.getName(), "thrown-by-guard"));
        assertNull(metadata.get(TikaCoreProperties.TIKA_CONTENT));
        metadata.reconstruct(TikaCoreProperties.TIKA_CONTENT.getName(), "lands-via-reconstruct", false);
        assertEquals("lands-via-reconstruct", metadata.get(TikaCoreProperties.TIKA_CONTENT));

        // reserved but unregistered
        String unregistered = TikaCoreProperties.TIKA_META_PREFIX + "noSuchRegisteredProperty2";
        assertThrows(IllegalArgumentException.class, () -> metadata.set(unregistered, "thrown-by-guard"));
        assertNull(metadata.get(unregistered));
        metadata.reconstruct(unregistered, "lands-via-reconstruct", false);
        assertEquals("lands-via-reconstruct", metadata.get(unregistered));
    }
}