001/*
002 * Configurate
003 * Copyright (C) zml and Configurate contributors
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *    http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.spongepowered.configurate.xml;
018
019import org.checkerframework.checker.nullness.qual.NonNull;
020import org.checkerframework.checker.nullness.qual.Nullable;
021import org.spongepowered.configurate.AttributedConfigurationNode;
022import org.spongepowered.configurate.CommentedConfigurationNodeIntermediary;
023import org.spongepowered.configurate.ConfigurateException;
024import org.spongepowered.configurate.ConfigurationNode;
025import org.spongepowered.configurate.ConfigurationOptions;
026import org.spongepowered.configurate.loader.AbstractConfigurationLoader;
027import org.spongepowered.configurate.loader.CommentHandler;
028import org.spongepowered.configurate.loader.CommentHandlers;
029import org.spongepowered.configurate.loader.LoaderOptionSource;
030import org.spongepowered.configurate.loader.ParsingException;
031import org.spongepowered.configurate.util.UnmodifiableCollections;
032import org.w3c.dom.Document;
033import org.w3c.dom.Element;
034import org.w3c.dom.NamedNodeMap;
035import org.w3c.dom.Node;
036import org.w3c.dom.NodeList;
037import org.xml.sax.InputSource;
038import org.xml.sax.SAXException;
039import org.xml.sax.SAXParseException;
040
041import java.io.BufferedReader;
042import java.io.FileNotFoundException;
043import java.io.IOException;
044import java.io.Writer;
045import java.nio.file.NoSuchFileException;
046import java.util.ArrayList;
047import java.util.Collection;
048import java.util.Collections;
049import java.util.LinkedHashMap;
050import java.util.Map;
051import java.util.Objects;
052import java.util.Set;
053import java.util.stream.Collectors;
054
055import javax.xml.XMLConstants;
056import javax.xml.parsers.DocumentBuilder;
057import javax.xml.parsers.DocumentBuilderFactory;
058import javax.xml.parsers.ParserConfigurationException;
059import javax.xml.transform.OutputKeys;
060import javax.xml.transform.Transformer;
061import javax.xml.transform.TransformerConfigurationException;
062import javax.xml.transform.TransformerException;
063import javax.xml.transform.TransformerFactory;
064import javax.xml.transform.dom.DOMSource;
065import javax.xml.transform.stream.StreamResult;
066import javax.xml.validation.Schema;
067
068/**
069 * A loader for XML (Extensible Markup Language), using the native javax library
070 * for parsing and generation.
071 *
072 * @since 4.0.0
073 */
074public final class XmlConfigurationLoader extends AbstractConfigurationLoader<AttributedConfigurationNode> {
075
076    private static final Set<Class<?>> NATIVE_TYPES = UnmodifiableCollections.toSet(Double.class, Long.class,
077            Integer.class, Boolean.class, String.class, Number.class);
078
079    /**
080     * The prefix of lines within the header.
081     */
082    private static final String HEADER_PREFIX = "~";
083
084    private static final String ATTRIBUTE_TYPE = "configurate-type";
085
086    /**
087     * The user data used to store comments on nodes.
088     */
089    private static final String USER_DATA_COMMENT = "configurate-comment";
090
091    /**
092     * The property used to mark how many spaces should be used to indent.
093     */
094    private static final String INDENT_PROPERTY = "{http://xml.apache.org/xslt}indent-amount";
095
096    private static final String FEATURE_EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities";
097
098    private static final String FEATURE_EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities";
099
100    private static final String FEATURE_LOAD_EXTERNAL_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
101
102    /**
103     * Creates a new {@link XmlConfigurationLoader} builder.
104     *
105     * @return a new builder
106     * @since 4.0.0
107     */
108    public static @NonNull Builder builder() {
109        return new Builder();
110    }
111
112    /**
113     * Builds a {@link XmlConfigurationLoader}.
114     *
115     * <p>This builder supports the following options:</p>
116     * <dl>
117     *     <dt>&lt;prefix&gt;.xml.default-tag-name</dt>
118     *     <dd>Equivalent to {@link #defaultTagName(String)}</dd>
119     *     <dt>&lt;prefix&gt;.xml.indent</dt>
120     *     <dd>Equivalent to {@link #indent(int)}</dd>
121     *     <dt>&lt;prefix&gt;.xml.writes-explicit-type</dt>
122     *     <dd>Equivalent to {@link #writesExplicitType(boolean)}</dd>
123     *     <dt>&lt;prefix&gt;.xml.resolves-external-content</dt>
124     *     <dd>Equivalent to {@link #resolvesExternalContent(boolean)}</dd>
125     *     <dt>&lt;prefix&gt;.xml.includes-xml-declaration</dt>
126     *     <dd>Equivalent to {@link #includesXmlDeclaration(boolean)}</dd>
127     * </dl>
128     *
129     * @since 4.0.0
130     */
131    public static final class Builder extends AbstractConfigurationLoader.Builder<Builder, XmlConfigurationLoader> {
132        private @Nullable Schema schema;
133        private String defaultTagName = "element";
134        private int indent = 2;
135        private boolean writeExplicitType = true;
136        private boolean resolvesExternalContent;
137        private boolean includeXmlDeclaration = true;
138
139        Builder() {
140            this.from(DEFAULT_OPTIONS_SOURCE);
141        }
142
143        @Override
144        protected void populate(final LoaderOptionSource options) {
145            this.defaultTagName = options.getOr(this.defaultTagName, "xml", "default-tag-name");
146            this.indent = options.getInt(this.indent, "xml", "indent");
147            this.writeExplicitType = options.getBoolean(this.writeExplicitType, "xml", "writes-explicit-type");
148            this.resolvesExternalContent = options.getBoolean(this.resolvesExternalContent, "xml", "resolves-external-content");
149            this.includeXmlDeclaration = options.getBoolean(this.includeXmlDeclaration, "xml", "includes-xml-declaration");
150        }
151
152        /**
153         * Sets the level of indentation the resultant loader should use.
154         *
155         * @param indent the indent level
156         * @return this builder (for chaining)
157         * @since 4.0.0
158         */
159        public @NonNull Builder indent(final int indent) {
160            this.indent = indent;
161            return this;
162        }
163
164        /**
165         * Gets the level of indentation to be used by the resultant loader.
166         *
167         * @return the indent level
168         * @since 4.0.0
169         */
170        public int indent() {
171            return this.indent;
172        }
173
174        /**
175         * Sets the {@link Schema} the resultant loader should use.
176         *
177         * @param schema the schema
178         * @return this builder (for chaining)
179         * @since 4.0.0
180         */
181        public Builder schema(final @Nullable Schema schema) {
182            this.schema = schema;
183            return this;
184        }
185
186        /**
187         * Gets the {@link Schema} to be used by the resultant loader.
188         *
189         * @return the schema
190         * @since 4.0.0
191         */
192        public @Nullable Schema schema() {
193            return this.schema;
194        }
195
196        /**
197         * Sets the default tag name the resultant loader should use.
198         *
199         * @param defaultTagName the default tag name
200         * @return this builder (for chaining)
201         * @since 4.0.0
202         */
203        public Builder defaultTagName(final String defaultTagName) {
204            this.defaultTagName = defaultTagName;
205            return this;
206        }
207
208        /**
209         * Gets the default tag name to be used by the resultant loader.
210         *
211         * @return the default tag name
212         * @since 4.0.0
213         */
214        public @NonNull String defaultTagName() {
215            return this.defaultTagName;
216        }
217
218        /**
219         * Sets if the resultant loader should write the explicit type of each
220         * node when saving nodes.
221         *
222         * <p>This is necessary in some cases, as XML has no explicit definition
223         * of an array or list. The loader is able to infer the type in some
224         * cases, but this is inaccurate in some cases, for example lists with
225         * only one element.</p>
226         *
227         * @param writeExplicitType if the loader should write explicit types
228         * @return this builder (for chaining)
229         * @since 4.0.0
230         */
231        public Builder writesExplicitType(final boolean writeExplicitType) {
232            this.writeExplicitType = writeExplicitType;
233            return this;
234        }
235
236        /**
237         * Gets if explicit type attributes should be written by the loader.
238         *
239         * <p>See the method doc at {@link #writesExplicitType(boolean)} for
240         * a more detailed explanation.</p>
241         *
242         * @return the default tag name
243         * @since 4.0.0
244         */
245        public boolean writesExplicitType() {
246            return this.writeExplicitType;
247        }
248
249        /**
250         * Sets if the resultant loader should include the XML declaration
251         * header when saving.
252         *
253         * @param includeXmlDeclaration if the XML declaration should be
254         *                              included
255         * @return this builder (for chaining)
256         * @since 4.0.0
257         */
258        public Builder includesXmlDeclaration(final boolean includeXmlDeclaration) {
259            this.includeXmlDeclaration = includeXmlDeclaration;
260            return this;
261        }
262
263        /**
264         * Gets if the resultant loader should include the XML declaration
265         * header when saving.
266         *
267         * @return if the XML declaration should be included
268         * @since 4.0.0
269         */
270        public boolean includesXmlDeclaration() {
271            return this.includeXmlDeclaration;
272        }
273
274        /**
275         * Sets whether external content should be resolved when loading data.
276         *
277         * <p>Resolving this content could result in network requests being
278         * made, and will allow configuration files to access arbitrary URLs
279         * This setting should only be enabled with caution.
280         *
281         * <p>Additionally, through use of features such as entity expansion and
282         * XInclude, documents can be crafted that will grow exponentially
283         * when parsed, requiring an amount of memory to store that may be
284         * greater than what is available for the JVM.
285         *
286         * <p>By default, this is false.
287         *
288         * @param resolvesExternalContent whether to resolve external entities
289         * @return this builder
290         * @since 4.0.0
291         */
292        public Builder resolvesExternalContent(final boolean resolvesExternalContent) {
293            this.resolvesExternalContent = resolvesExternalContent;
294            return this;
295        }
296
297        /**
298         * Get whether external content should be resolved.
299         *
300         * @return value, defaulting to false
301         * @since 4.0.0
302         */
303        public boolean resolvesExternalContent() {
304            return this.resolvesExternalContent;
305        }
306
307        @Override
308        public XmlConfigurationLoader build() {
309            this.defaultOptions(o -> o.nativeTypes(NATIVE_TYPES));
310            return new XmlConfigurationLoader(this);
311        }
312    }
313
314    private final @Nullable Schema schema;
315    private final String defaultTagName;
316    private final int indent;
317    private final boolean writeExplicitType;
318    private final boolean includeXmlDeclaration;
319    private final boolean resolvesExternalContent;
320
321    private XmlConfigurationLoader(final Builder builder) {
322        super(builder, new CommentHandler[] {CommentHandlers.XML_STYLE});
323        this.schema = builder.schema();
324        this.defaultTagName = builder.defaultTagName();
325        this.indent = builder.indent();
326        this.writeExplicitType = builder.writesExplicitType();
327        this.includeXmlDeclaration = builder.includesXmlDeclaration();
328        this.resolvesExternalContent = builder.resolvesExternalContent();
329    }
330
331    private DocumentBuilder newDocumentBuilder() throws ConfigurateException {
332        final DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
333        if (this.schema != null) {
334            builderFactory.setSchema(this.schema);
335        }
336        if (!this.resolvesExternalContent) {
337            // Settings based on https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
338            try {
339                builderFactory.setFeature(FEATURE_EXTERNAL_GENERAL_ENTITIES, false);
340                builderFactory.setFeature(FEATURE_EXTERNAL_PARAMETER_ENTITIES, false);
341                builderFactory.setFeature(FEATURE_LOAD_EXTERNAL_DTD, false);
342            } catch (final ParserConfigurationException e) {
343                throw new ConfigurateException(e);
344            }
345            builderFactory.setXIncludeAware(false);
346            builderFactory.setExpandEntityReferences(false);
347        }
348
349        try {
350            return builderFactory.newDocumentBuilder();
351        } catch (final ParserConfigurationException e) {
352            throw new ConfigurateException(e);
353        }
354    }
355
356    private Transformer newTransformer() throws ConfigurateException {
357        final TransformerFactory transformerFactory = TransformerFactory.newInstance();
358        if (!this.resolvesExternalContent) {
359            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
360            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
361        }
362        try {
363            final Transformer transformer = transformerFactory.newTransformer();
364
365            // we write the header ourselves.
366            transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
367
368            if (this.indent > 0) {
369                transformer.setOutputProperty(OutputKeys.INDENT, "yes");
370                transformer.setOutputProperty(INDENT_PROPERTY, Integer.toString(this.indent));
371            }
372            return transformer;
373        } catch (final TransformerConfigurationException e) {
374            throw new ConfigurateException(e);
375        }
376    }
377
378    @Override
379    public @NonNull AttributedConfigurationNode load(@NonNull ConfigurationOptions options) throws ParsingException {
380        if (this.source == null) {
381            throw new ParsingException(-1, -1, "", "No source present to read from!", null);
382        }
383        try (BufferedReader reader = this.source.call()) {
384            final DocumentBuilder documentBuilder = this.newDocumentBuilder();
385
386            final Document document;
387            try {
388                document = documentBuilder.parse(new InputSource(reader));
389            } catch (final SAXParseException ex) {
390                throw new ParsingException(ex.getLineNumber(), ex.getColumnNumber(), "", ex.getMessage(), ex.getCause());
391            } catch (final SAXException e) {
392                throw new ParsingException(-1, -1, null, null, e);
393            }
394
395            final NodeList children = document.getChildNodes();
396            for (int i = 0; i < children.getLength(); ++i) {
397                final Node child = children.item(i);
398                if (child.getNodeType() == Node.COMMENT_NODE) {
399                    options = options.header(this.unwrapHeader(child.getTextContent().trim()));
400                } else if (child.getNodeType() == Node.ELEMENT_NODE) {
401                    final AttributedConfigurationNode node = this.createNode(options);
402                    this.readElement(child, node);
403                    return node;
404                }
405            }
406            // empty document, fall through
407        } catch (final FileNotFoundException | NoSuchFileException e) {
408            // Squash -- there's nothing to read
409        } catch (final ParsingException ex) {
410            throw ex;
411        } catch (final Exception e) {
412            throw new ParsingException(-1, -1, "", null, e);
413        }
414        return this.createNode(options);
415    }
416
417    /**
418     * Given a single comment node's comment, clear any prefix lines.
419     *
420     * @param headerContent the content of a header
421     * @return a formatted header, with lines separated by {@link #CONFIGURATE_LINE_SEPARATOR}
422     */
423    private String unwrapHeader(final String headerContent) {
424        if (headerContent.isEmpty()) {
425            return headerContent;
426        }
427        // TODO: 4.0 may have changed behaviour here when moving away from Guava
428        return CONFIGURATE_LINE_PATTERN.splitAsStream(headerContent)
429                .map(line -> {
430                    final String trimmedLine = line.trim();
431                    if (trimmedLine.startsWith(HEADER_PREFIX)) {
432                        line = line.substring(line.indexOf(HEADER_PREFIX) + 1);
433                    }
434
435                    if (line.length() > 0 && line.charAt(0) == ' ') {
436                        line = line.substring(1);
437                    }
438                    return line;
439                }).filter(line -> !line.isEmpty())
440                .collect(Collectors.joining(CONFIGURATE_LINE_SEPARATOR));
441    }
442
443    @Override
444    protected void loadInternal(final AttributedConfigurationNode node, final BufferedReader reader) {
445        throw new UnsupportedOperationException("XMLConfigurationLoader provides custom loading logic to handle headers");
446    }
447
448    private enum NodeType {
449        MAP, LIST
450    }
451
452    private void readElement(final Node from, final AttributedConfigurationNode to) {
453        @Nullable NodeType type = null;
454
455        // copy the name of the tag
456        to.tagName(from.getNodeName());
457
458        final String potentialComment = (String) from.getUserData(USER_DATA_COMMENT);
459        if (potentialComment != null) {
460            to.comment(potentialComment);
461        }
462
463        // copy attributes
464        if (from.hasAttributes()) {
465            final NamedNodeMap attributes = from.getAttributes();
466            for (int i = 0; i < attributes.getLength(); i++) {
467                final Node attribute = attributes.item(i);
468                final String key = attribute.getNodeName();
469                final String value = attribute.getNodeValue();
470
471                // read the type of the node
472                if (key.equals(ATTRIBUTE_TYPE)) {
473                    if (value.equals("map")) {
474                        type = NodeType.MAP;
475                    } else if (value.equals("list")) {
476                        type = NodeType.LIST;
477                    }
478
479                    // don't add internal configurate attributes to the node
480                    continue;
481                }
482
483                to.addAttribute(key, value);
484            }
485        }
486
487        // read out the child nodes into a multimap
488        final Map<String, Collection<Node>> children = new LinkedHashMap<>();
489        if (from.hasChildNodes()) {
490            final StringBuilder comment = new StringBuilder();
491            final NodeList childNodes = from.getChildNodes();
492            for (int i = 0; i < childNodes.getLength(); i++) {
493                final Node child = childNodes.item(i);
494                if (child.getNodeType() == Node.ELEMENT_NODE) {
495                    children.computeIfAbsent(child.getNodeName(), $ -> new ArrayList<>()).add(child);
496                    if (comment.length() > 0) {
497                        child.setUserData(USER_DATA_COMMENT, comment.toString(), null);
498                        comment.setLength(0);
499                    }
500                } else if (child.getNodeType() == Node.COMMENT_NODE) {
501                    if (comment.length() > 0) {
502                        comment.append('\n');
503                    }
504
505                    comment.append(child.getTextContent().trim());
506                }
507            }
508        }
509
510        // if there are no child nodes present, assume it's a scalar value
511        if (children.isEmpty()) {
512            to.raw(parseValue(from.getTextContent()));
513            return;
514        }
515
516        // if type is null, we need to infer what type the element is
517        if (type == null) {
518            // if there are no duplicate keys, we can infer that it is a map
519            // otherwise, assume it's a list
520            type = NodeType.MAP;
521            for (final Collection<Node> child : children.values()) {
522                if (child.size() > 1) {
523                    type = NodeType.LIST;
524                    break;
525                }
526            }
527        }
528
529        if (type == NodeType.MAP) {
530            to.raw(Collections.emptyMap());
531        } else {
532            to.raw(Collections.emptyList());
533        }
534
535        // read out the elements
536        for (final Map.Entry<String, Collection<Node>> entry : children.entrySet()) {
537            AttributedConfigurationNode child;
538            if (type == NodeType.MAP) {
539                child = to.node(entry.getKey());
540                this.readElement(entry.getValue().iterator().next(), child);
541            } else {
542                for (final Node element : entry.getValue()) {
543                    child = to.appendListNode();
544                    this.readElement(element, child);
545                }
546            }
547        }
548    }
549
550    @Override
551    protected void writeHeaderInternal(final Writer writer) throws IOException {
552        if (this.includeXmlDeclaration) {
553            writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
554            writer.write(SYSTEM_LINE_SEPARATOR);
555        }
556    }
557
558    @Override
559    protected void saveInternal(final ConfigurationNode node, final Writer writer) throws ConfigurateException {
560        final DocumentBuilder documentBuilder = this.newDocumentBuilder();
561        final Document document = documentBuilder.newDocument();
562
563        final @Nullable Node comment = this.createCommentNode(document, node);
564        if (comment != null) {
565            document.appendChild(comment);
566        }
567
568        document.appendChild(this.writeNode(document, node, null));
569
570        final Transformer transformer = this.newTransformer();
571        final DOMSource source = new DOMSource(document);
572        try {
573            transformer.transform(source, new StreamResult(writer));
574        } catch (final TransformerException e) {
575            throw new ConfigurateException(node, e);
576        }
577    }
578
579    private void appendCommentIfNecessary(final Element parent, final ConfigurationNode node) {
580        final @Nullable Node possibleComment = this.createCommentNode(parent.getOwnerDocument(), node);
581        if (possibleComment != null) {
582            parent.appendChild(possibleComment);
583        }
584    }
585
586    private @Nullable Node createCommentNode(final Document doc, final ConfigurationNode node) {
587        if (node instanceof CommentedConfigurationNodeIntermediary<?>) {
588            final @Nullable String comment = ((CommentedConfigurationNodeIntermediary<?>) node).comment();
589            if (comment != null) {
590                return doc.createComment(" " + comment.trim() + " ");
591            }
592        }
593        return null;
594    }
595
596    private Element writeNode(final Document document, final ConfigurationNode node, final @Nullable String forcedTag) {
597        String tag = this.defaultTagName;
598        Map<String, String> attributes = Collections.emptyMap();
599
600        if (node instanceof AttributedConfigurationNode) {
601            final AttributedConfigurationNode attributedNode = (AttributedConfigurationNode) node;
602            tag = attributedNode.tagName();
603            attributes = attributedNode.attributes();
604        }
605
606        final Element element = document.createElement(forcedTag == null ? tag : forcedTag);
607        for (final Map.Entry<String, String> attribute : attributes.entrySet()) {
608            element.setAttribute(attribute.getKey(), attribute.getValue());
609        }
610
611        if (node.isMap()) {
612            for (final Map.Entry<Object, ? extends ConfigurationNode> child : node.childrenMap().entrySet()) {
613                this.appendCommentIfNecessary(element, child.getValue());
614                element.appendChild(this.writeNode(document, child.getValue(), child.getKey().toString()));
615            }
616        } else if (node.isList()) {
617            if (this.writeExplicitType) {
618                element.setAttribute(ATTRIBUTE_TYPE, "list");
619            }
620            for (final ConfigurationNode child : node.childrenList()) {
621                this.appendCommentIfNecessary(element, child);
622                element.appendChild(this.writeNode(document, child, null));
623            }
624        } else {
625            element.appendChild(document.createTextNode(Objects.toString(node.rawScalar())));
626        }
627
628        return element;
629    }
630
631    @Override
632    public AttributedConfigurationNode createNode(ConfigurationOptions options) {
633        options = options.nativeTypes(NATIVE_TYPES);
634        return AttributedConfigurationNode.root("root", options);
635    }
636
637    private static Object parseValue(final String value) {
638        if (value.equals("true") || value.equals("false")) {
639            return Boolean.parseBoolean(value);
640        }
641
642        try {
643            final double doubleValue = Double.parseDouble(value);
644            if (isInteger(doubleValue)) {
645                final long longValue = Long.parseLong(value); // prevent losing precision
646                final int intValue = (int) longValue;
647                if (longValue == intValue) {
648                    return intValue;
649                } else {
650                    return longValue;
651                }
652            }
653            return doubleValue;
654        } catch (final NumberFormatException e) {
655            return value;
656        }
657    }
658
659    private static boolean isInteger(final double value) {
660        return !Double.isNaN(value) && Double.isFinite(value) && value == Math.rint(value);
661    }
662
663}