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