001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  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 */
017
018package org.apache.commons.configuration2;
019
020import java.io.PrintWriter;
021import java.io.Reader;
022import java.io.Writer;
023import java.nio.charset.StandardCharsets;
024import java.util.Iterator;
025import java.util.List;
026import java.util.Objects;
027
028import javax.xml.parsers.SAXParser;
029import javax.xml.parsers.SAXParserFactory;
030
031import org.apache.commons.configuration2.convert.ListDelimiterHandler;
032import org.apache.commons.configuration2.ex.ConfigurationException;
033import org.apache.commons.configuration2.io.FileLocator;
034import org.apache.commons.configuration2.io.FileLocatorAware;
035import org.apache.commons.text.StringEscapeUtils;
036import org.w3c.dom.Document;
037import org.w3c.dom.Element;
038import org.w3c.dom.Node;
039import org.w3c.dom.NodeList;
040import org.xml.sax.Attributes;
041import org.xml.sax.InputSource;
042import org.xml.sax.XMLReader;
043import org.xml.sax.helpers.DefaultHandler;
044
045/**
046 * This configuration implements the XML properties format introduced in Java, see
047 * https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html. An XML properties file looks like this:
048 *
049 * <pre>
050 * &lt;?xml version="1.0"?&gt;
051 * &lt;!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd"&gt;
052 * &lt;properties&gt;
053 *   &lt;comment&gt;Description of the property list&lt;/comment&gt;
054 *   &lt;entry key="key1"&gt;value1&lt;/entry&gt;
055 *   &lt;entry key="key2"&gt;value2&lt;/entry&gt;
056 *   &lt;entry key="key3"&gt;value3&lt;/entry&gt;
057 * &lt;/properties&gt;
058 * </pre>
059 *
060 * The Java runtime is not required to use this class. The default encoding for this configuration format is UTF-8.
061 * Note that unlike {@code PropertiesConfiguration}, {@code XMLPropertiesConfiguration} does not support includes.
062 *
063 * <em>Note:</em>Configuration objects of this type can be read concurrently by multiple threads. However if one of
064 * these threads modifies the object, synchronization has to be performed manually.
065 *
066 * @since 1.1
067 */
068public class XMLPropertiesConfiguration extends BaseConfiguration implements FileBasedConfiguration, FileLocatorAware {
069
070    /**
071     * The default encoding (UTF-8 as specified by https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html)
072     */
073    public static final String DEFAULT_ENCODING = StandardCharsets.UTF_8.name();
074
075    /**
076     * Default string used when the XML is malformed
077     */
078    private static final String MALFORMED_XML_EXCEPTION = "Malformed XML";
079
080    /** The temporary file locator. */
081    private FileLocator locator;
082
083    /** Stores a header comment. */
084    private String header;
085
086    /**
087     * Creates an empty XMLPropertyConfiguration object which can be used to synthesize a new Properties file by adding
088     * values and then saving(). An object constructed by this C'tor can not be tickled into loading included files because
089     * it cannot supply a base for relative includes.
090     */
091    public XMLPropertiesConfiguration() {
092    }
093
094    /**
095     * Creates and loads the XML properties from the specified DOM node.
096     *
097     * @param element The non-null DOM element.
098     * @throws ConfigurationException Error while loading the Element.
099     * @since 2.0
100     */
101    public XMLPropertiesConfiguration(final Element element) throws ConfigurationException {
102        load(Objects.requireNonNull(element, "element"));
103    }
104
105    /**
106     * Gets the header comment of this configuration.
107     *
108     * @return the header comment
109     */
110    public String getHeader() {
111        return header;
112    }
113
114    /**
115     * Sets the header comment of this configuration.
116     *
117     * @param header the header comment
118     */
119    public void setHeader(final String header) {
120        this.header = header;
121    }
122
123    @Override
124    public void read(final Reader in) throws ConfigurationException {
125        final SAXParserFactory factory = SAXParserFactory.newInstance();
126        factory.setNamespaceAware(false);
127        factory.setValidating(true);
128
129        try {
130            final SAXParser parser = factory.newSAXParser();
131
132            final XMLReader xmlReader = parser.getXMLReader();
133            xmlReader.setEntityResolver((publicId, systemId) -> new InputSource(getClass().getClassLoader().getResourceAsStream("properties.dtd")));
134            xmlReader.setContentHandler(new XMLPropertiesHandler());
135            xmlReader.parse(new InputSource(in));
136        } catch (final Exception e) {
137            throw new ConfigurationException("Unable to parse the configuration file", e);
138        }
139
140        // todo: support included properties ?
141    }
142
143    /**
144     * Parses a DOM element containing the properties. The DOM element has to follow the XML properties format introduced in
145     * Java, see https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html
146     *
147     * @param element The DOM element
148     * @throws ConfigurationException Error while interpreting the DOM
149     * @since 2.0
150     */
151    public void load(final Element element) throws ConfigurationException {
152        if (!element.getNodeName().equals("properties")) {
153            throw new ConfigurationException(MALFORMED_XML_EXCEPTION);
154        }
155        final NodeList childNodes = element.getChildNodes();
156        for (int i = 0; i < childNodes.getLength(); i++) {
157            final Node item = childNodes.item(i);
158            if (item instanceof Element) {
159                if (item.getNodeName().equals("comment")) {
160                    setHeader(item.getTextContent());
161                } else if (item.getNodeName().equals("entry")) {
162                    final String key = ((Element) item).getAttribute("key");
163                    addProperty(key, item.getTextContent());
164                } else {
165                    throw new ConfigurationException(MALFORMED_XML_EXCEPTION);
166                }
167            }
168        }
169    }
170
171    @Override
172    public void write(final Writer out) throws ConfigurationException {
173        final PrintWriter writer = new PrintWriter(out);
174
175        String encoding = locator != null ? locator.getEncoding() : null;
176        if (encoding == null) {
177            encoding = DEFAULT_ENCODING;
178        }
179        writer.println("<?xml version=\"1.0\" encoding=\"" + encoding + "\"?>");
180        writer.println("<!DOCTYPE properties SYSTEM \"http://java.sun.com/dtd/properties.dtd\">");
181        writer.println("<properties>");
182
183        if (getHeader() != null) {
184            writer.println("  <comment>" + StringEscapeUtils.escapeXml10(getHeader()) + "</comment>");
185        }
186
187        final Iterator<String> keys = getKeys();
188        while (keys.hasNext()) {
189            final String key = keys.next();
190            final Object value = getProperty(key);
191
192            if (value instanceof List) {
193                writeProperty(writer, key, (List<?>) value);
194            } else {
195                writeProperty(writer, key, value);
196            }
197        }
198
199        writer.println("</properties>");
200        writer.flush();
201    }
202
203    /**
204     * Write a property.
205     *
206     * @param out the output stream
207     * @param key the key of the property
208     * @param value the value of the property
209     */
210    private void writeProperty(final PrintWriter out, final String key, final Object value) {
211        // escape the key
212        final String k = StringEscapeUtils.escapeXml10(key);
213
214        if (value != null) {
215            final String v = escapeValue(value);
216            out.println("  <entry key=\"" + k + "\">" + v + "</entry>");
217        } else {
218            out.println("  <entry key=\"" + k + "\"/>");
219        }
220    }
221
222    /**
223     * Write a list property.
224     *
225     * @param out the output stream
226     * @param key the key of the property
227     * @param values a list with all property values
228     */
229    private void writeProperty(final PrintWriter out, final String key, final List<?> values) {
230        values.forEach(value -> writeProperty(out, key, value));
231    }
232
233    /**
234     * Writes the configuration as child to the given DOM node
235     *
236     * @param document The DOM document to add the configuration to
237     * @param parent The DOM parent node
238     * @since 2.0
239     */
240    public void save(final Document document, final Node parent) {
241        final Element properties = document.createElement("properties");
242        parent.appendChild(properties);
243        if (getHeader() != null) {
244            final Element comment = document.createElement("comment");
245            properties.appendChild(comment);
246            comment.setTextContent(StringEscapeUtils.escapeXml10(getHeader()));
247        }
248
249        final Iterator<String> keys = getKeys();
250        while (keys.hasNext()) {
251            final String key = keys.next();
252            final Object value = getProperty(key);
253
254            if (value instanceof List) {
255                writeProperty(document, properties, key, (List<?>) value);
256            } else {
257                writeProperty(document, properties, key, value);
258            }
259        }
260    }
261
262    /**
263     * Initializes this object with a {@code FileLocator}. The locator is accessed during load and save operations.
264     *
265     * @param locator the associated {@code FileLocator}
266     */
267    @Override
268    public void initFileLocator(final FileLocator locator) {
269        this.locator = locator;
270    }
271
272    private void writeProperty(final Document document, final Node properties, final String key, final Object value) {
273        final Element entry = document.createElement("entry");
274        properties.appendChild(entry);
275
276        // escape the key
277        final String k = StringEscapeUtils.escapeXml10(key);
278        entry.setAttribute("key", k);
279
280        if (value != null) {
281            final String v = escapeValue(value);
282            entry.setTextContent(v);
283        }
284    }
285
286    private void writeProperty(final Document document, final Node properties, final String key, final List<?> values) {
287        values.forEach(value -> writeProperty(document, properties, key, value));
288    }
289
290    /**
291     * Escapes a property value before it is written to disk.
292     *
293     * @param value the value to be escaped
294     * @return the escaped value
295     */
296    private String escapeValue(final Object value) {
297        final String v = StringEscapeUtils.escapeXml10(String.valueOf(value));
298        return String.valueOf(getListDelimiterHandler().escape(v, ListDelimiterHandler.NOOP_TRANSFORMER));
299    }
300
301    /**
302     * SAX Handler to parse a XML properties file.
303     *
304     * @since 1.2
305     */
306    private final class XMLPropertiesHandler extends DefaultHandler {
307        /** The key of the current entry being parsed. */
308        private String key;
309
310        /** The value of the current entry being parsed. */
311        private StringBuilder value = new StringBuilder();
312
313        /** Indicates that a comment is being parsed. */
314        private boolean inCommentElement;
315
316        /** Indicates that an entry is being parsed. */
317        private boolean inEntryElement;
318
319        @Override
320        public void startElement(final String uri, final String localName, final String qName, final Attributes attrs) {
321            if ("comment".equals(qName)) {
322                inCommentElement = true;
323            }
324
325            if ("entry".equals(qName)) {
326                key = attrs.getValue("key");
327                inEntryElement = true;
328            }
329        }
330
331        @Override
332        public void endElement(final String uri, final String localName, final String qName) {
333            if (inCommentElement) {
334                // We've just finished a <comment> element so set the header
335                setHeader(value.toString());
336                inCommentElement = false;
337            }
338
339            if (inEntryElement) {
340                // We've just finished an <entry> element, so add the key/value pair
341                addProperty(key, value.toString());
342                inEntryElement = false;
343            }
344
345            // Clear the element value buffer
346            value = new StringBuilder();
347        }
348
349        @Override
350        public void characters(final char[] chars, final int start, final int length) {
351            /**
352             * We're currently processing an element. All character data from now until the next endElement() call will be the data
353             * for this element.
354             */
355            value.append(chars, start, length);
356        }
357    }
358}