XmpSaxFlattener.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.xmp;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import org.apache.commons.io.input.CloseShieldInputStream;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import org.apache.tika.exception.TikaException;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.utils.XMLReaderUtils;

/** SAX-flattens an RDF/XML XMP packet to xmpcore-style (uri, path, value) leaves. */
public final class XmpSaxFlattener {

    static final String RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
    static final String XMLNS = "http://www.w3.org/XML/1998/namespace";
    static final String META = "adobe:ns:meta/";   // x:xmpmeta/x:xapmeta wrapper + x:xmptk

    public List<XmpProperty> flatten(byte[] packet) throws IOException, TikaException, SAXException {
        return flatten(packet, new ParseContext());
    }

    public List<XmpProperty> flatten(byte[] packet, ParseContext context)
            throws IOException, TikaException, SAXException {
        try (InputStream is = new ByteArrayInputStream(packet)) {
            return flatten(is, context);
        }
    }

    public List<XmpProperty> flatten(InputStream packet, ParseContext context)
            throws IOException, TikaException, SAXException {
        Handler h = new Handler();
        // hardened pooled SAX (secure factory + entity cap + OfflineContentHandler); CloseShield the stream
        XMLReaderUtils.parseSAX(CloseShieldInputStream.wrap(packet), h, context);
        return h.out;
    }

    private static final class Handler extends DefaultHandler {
        // Caps so a hostile packet can't inflate metadata: leaf count, key/path length, value length.
        static final int MAX_LEAVES = 50_000;
        static final int MAX_PATH = 512;
        static final int MAX_VALUE = 1 << 20;   // 1 MB
        final List<XmpProperty> out = new ArrayList<>();
        final ArrayDeque<String> segs = new ArrayDeque<>();
        final ArrayDeque<String> uris = new ArrayDeque<>();     // leaf/property namespace per frame
        final ArrayDeque<int[]> liCount = new ArrayDeque<>();
        final ArrayDeque<StringBuilder> text = new ArrayDeque<>();
        final ArrayDeque<int[]> childCount = new ArrayDeque<>();
        final ArrayDeque<String[]> langs = new ArrayDeque<>();  // xml:lang per frame (holder so it can be set after push)
        final ArrayDeque<boolean[]> hasValue = new ArrayDeque<>();   // frame carries an explicit rdf:value

        static boolean isContainer(String u, String l) {
            return RDF.equals(u) && (l.equals("Bag") || l.equals("Seq") || l.equals("Alt"));
        }

        void add(XmpProperty p) {
            if (out.size() >= MAX_LEAVES || p.path.length() > MAX_PATH
                    || (p.value != null && p.value.length() > MAX_VALUE)) {
                return;   // drop over-cap leaves: count / absurd key / bloated value
            }
            out.add(p);
        }

        String path() {
            StringBuilder p = new StringBuilder();
            Iterator<String> it = segs.descendingIterator();
            while (it.hasNext()) {
                String s = it.next();
                if (s.startsWith("[")) {
                    p.append(s);
                } else {
                    if (p.length() > 0) {
                        p.append('/');
                    }
                    p.append(s);
                }
            }
            return p.toString();
        }

        void pushFrame(String seg, String uri) {
            if (!childCount.isEmpty()) {
                childCount.peek()[0]++;
            }
            segs.push(seg);
            uris.push(uri == null ? "" : uri);
            text.push(new StringBuilder());
            childCount.push(new int[]{0});
            langs.push(new String[]{null});
            hasValue.push(new boolean[]{false});
        }

        @Override
        public void startElement(String u, String l, String qn, Attributes a) {
            if (META.equals(u)) {
                return;
            }
            boolean rdf = RDF.equals(u);
            if (rdf && l.equals("RDF")) {
                return;
            }
            if (rdf && l.equals("Description")) {
                emitAttrs(a, false);
                return;
            }
            if (isContainer(u, l)) {
                liCount.push(new int[]{0});
                return;
            }
            if (rdf && l.equals("li")) {
                // a bare rdf:li outside a Bag/Seq/Alt (malformed) has no counter -> treat as [1]
                int idx = liCount.isEmpty() ? 1 : ++liCount.peek()[0];
                pushFrame("[" + idx + "]", uris.isEmpty() ? "" : uris.peek());
            } else if (rdf && l.equals("value")) {
                // rdf:value holds the frame's value even when qualifier siblings are present
                if (!hasValue.isEmpty()) {
                    hasValue.peek()[0] = true;
                }
                return;
            } else if (!rdf) {
                pushFrame(qn, u);
            } else {
                return;
            }
            emitAttrs(a, true);
        }

        void emitAttrs(Attributes a, boolean valueFrame) {
            String base = path();
            for (int i = 0; i < a.getLength(); i++) {
                String au = a.getURI(i);
                String al = a.getLocalName(i);
                String aq = a.getQName(i);
                String v = a.getValue(i);
                if (v == null) {
                    continue;
                }
                v = v.trim();
                if (v.isEmpty()) {
                    continue;
                }
                if (XMLNS.equals(au)) {
                    if (al.equals("lang")) {
                        if (valueFrame) {
                            langs.peek()[0] = v;   // attach to this value's frame
                        }
                        if (!base.isEmpty()) {
                            add(new XmpProperty(XMLNS, base + "/xml:lang", v));
                        }
                    }
                    continue;
                }
                if (RDF.equals(au)) {
                    if (al.equals("resource")) {
                        add(new XmpProperty(uris.isEmpty() ? "" : uris.peek(), base, v));
                    } else if (al.equals("about")) {   // empty rdf:about already skipped above
                        add(new XmpProperty(RDF, base.isEmpty() ? "rdf:about" : base + "/rdf:about", v));
                    }
                    continue;
                }
                if (au == null || au.isEmpty()) {
                    continue;
                }
                add(new XmpProperty(au, base.isEmpty() ? aq : base + "/" + aq, v));
            }
        }

        @Override
        public void characters(char[] c, int s, int len) {
            if (!text.isEmpty()) {
                text.peek().append(c, s, len);
            }
        }

        @Override
        public void endElement(String u, String l, String qn) {
            if (META.equals(u)) {
                return;
            }
            boolean rdf = RDF.equals(u);
            if (rdf && l.equals("RDF")) {
                return;
            }
            if (rdf && l.equals("Description")) {
                return;
            }
            if (isContainer(u, l)) {
                liCount.pop();
                return;
            }
            if (rdf && !l.equals("li")) {
                return;
            }
            String t = text.peek().toString().trim();
            // a frame is a leaf when it has no child elements, or when an rdf:value gave it a value
            if (!t.isEmpty() && (childCount.peek()[0] == 0 || hasValue.peek()[0])) {
                add(new XmpProperty(uris.peek(), path(), t, langs.peek()[0]));
            }
            segs.pop();
            uris.pop();
            text.pop();
            childCount.pop();
            langs.pop();
            hasValue.pop();
        }
    }
}