ExtendedXmp.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.parser.image;

import static java.nio.charset.StandardCharsets.US_ASCII;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.drew.lang.SequentialByteArrayReader;
import com.drew.lang.SequentialReader;

/**
 * Reassembles a large XMP packet split across JPEG APP1 segments ("Extended XMP", XMP Part 3).
 * Chunk stitching is borrowed almost verbatim from metadata-extractor's {@code XmpReader}
 * (Apache 2.0, Drew Noakes); we find the GUID by scanning the standard packet since Tika parses
 * the XMP itself.
 */
final class ExtendedXmp {

    private static final String XMP_JPEG_PREAMBLE = "http://ns.adobe.com/xap/1.0/\u0000";
    private static final String XMP_EXTENSION_JPEG_PREAMBLE =
            "http://ns.adobe.com/xmp/extension/\u0000";
    private static final int EXTENDED_XMP_GUID_LENGTH = 32;
    private static final int EXTENDED_XMP_INT_LENGTH = 4;
    // Tika: cap the declared length so a hostile chunk can't drive a huge allocation (not upstream).
    private static final int MAX_EXTENDED = 64 * 1024 * 1024;
    // allow any run of attribute/element syntax (=, quotes, '>', whitespace, newlines) before the GUID
    private static final Pattern HAS_EXTENDED =
            Pattern.compile("HasExtendedXMP[\"'>=\\s]*([A-Fa-f0-9]{32})");

    private ExtendedXmp() {
    }

    /** XMP packets (standard + reassembled extended, if any) from a JPEG's APP1 segment payloads. */
    static List<byte[]> assemble(Iterable<byte[]> segments) {
        // adapted from metadata-extractor XmpReader.readJpegSegments
        final int preambleLength = XMP_JPEG_PREAMBLE.length();
        final int extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length();
        String guid = null;
        byte[] standard = null;
        byte[] extendedBuffer = null;

        for (byte[] segmentBytes : segments) {
            if (segmentBytes.length >= preambleLength && XMP_JPEG_PREAMBLE
                    .equalsIgnoreCase(new String(segmentBytes, 0, preambleLength, US_ASCII))) {
                standard = new byte[segmentBytes.length - preambleLength];
                System.arraycopy(segmentBytes, preambleLength, standard, 0, standard.length);
                guid = findGuid(standard);
                extendedBuffer = null;   // a new standard packet starts a fresh extended assembly
            } else if (guid != null && segmentBytes.length >= extensionPreambleLength
                    && XMP_EXTENSION_JPEG_PREAMBLE.equalsIgnoreCase(
                            new String(segmentBytes, 0, extensionPreambleLength, US_ASCII))) {
                extendedBuffer = processExtendedXMPChunk(segmentBytes, guid, extendedBuffer);
            }
        }

        List<byte[]> packets = new ArrayList<>();
        if (standard != null) {
            packets.add(standard);
        }
        if (extendedBuffer != null) {
            packets.add(extendedBuffer);
        }
        return packets;
    }

    private static String findGuid(byte[] standard) {
        Matcher m = HAS_EXTENDED.matcher(new String(standard, US_ASCII));
        return m.find() ? m.group(1) : null;
    }

    // Borrowed almost verbatim from metadata-extractor XmpReader.processExtendedXMPChunk (Apache 2.0).
    private static byte[] processExtendedXMPChunk(byte[] segmentBytes, String extendedXMPGUID,
                                                  byte[] extendedXMPBuffer) {
        final int extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length();
        final int segmentLength = segmentBytes.length;
        final int totalOffset = extensionPreambleLength + EXTENDED_XMP_GUID_LENGTH
                + EXTENDED_XMP_INT_LENGTH + EXTENDED_XMP_INT_LENGTH;
        if (segmentLength >= totalOffset) {
            try {
                final SequentialReader reader = new SequentialByteArrayReader(segmentBytes);
                reader.skip(extensionPreambleLength);
                final String segmentGUID = reader.getString(EXTENDED_XMP_GUID_LENGTH);
                if (extendedXMPGUID.equals(segmentGUID)) {
                    final int fullLength = (int) reader.getUInt32();
                    final int chunkOffset = (int) reader.getUInt32();
                    if (extendedXMPBuffer == null) {
                        if (fullLength <= 0 || fullLength > MAX_EXTENDED) {   // Tika OOM guard
                            return null;
                        }
                        extendedXMPBuffer = new byte[fullLength];
                    }
                    if (extendedXMPBuffer.length == fullLength) {
                        int copyLength = segmentLength - totalOffset;
                        if (chunkOffset >= 0 && chunkOffset <= extendedXMPBuffer.length - copyLength) {
                            System.arraycopy(segmentBytes, totalOffset, extendedXMPBuffer, chunkOffset,
                                    copyLength);
                        }
                    }
                }
            } catch (IOException ex) {
                // best effort: keep whatever chunks were already assembled
            }
        }
        return extendedXMPBuffer;
    }
}