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.plist;
019
020import java.io.PrintWriter;
021import java.io.Reader;
022import java.io.Writer;
023import java.math.BigDecimal;
024import java.math.BigInteger;
025import java.nio.charset.Charset;
026import java.nio.charset.StandardCharsets;
027import java.text.DateFormat;
028import java.text.ParseException;
029import java.text.SimpleDateFormat;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.Base64;
033import java.util.Calendar;
034import java.util.Collection;
035import java.util.Date;
036import java.util.HashMap;
037import java.util.Iterator;
038import java.util.LinkedList;
039import java.util.List;
040import java.util.Map;
041import java.util.TimeZone;
042
043import javax.xml.parsers.SAXParserFactory;
044
045import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
046import org.apache.commons.configuration2.FileBasedConfiguration;
047import org.apache.commons.configuration2.HierarchicalConfiguration;
048import org.apache.commons.configuration2.ImmutableConfiguration;
049import org.apache.commons.configuration2.MapConfiguration;
050import org.apache.commons.configuration2.ex.ConfigurationException;
051import org.apache.commons.configuration2.ex.ConfigurationRuntimeException;
052import org.apache.commons.configuration2.io.FileLocator;
053import org.apache.commons.configuration2.io.FileLocatorAware;
054import org.apache.commons.configuration2.tree.ImmutableNode;
055import org.apache.commons.configuration2.tree.InMemoryNodeModel;
056import org.apache.commons.lang3.StringUtils;
057import org.apache.commons.text.StringEscapeUtils;
058import org.xml.sax.Attributes;
059import org.xml.sax.EntityResolver;
060import org.xml.sax.InputSource;
061import org.xml.sax.SAXException;
062import org.xml.sax.XMLReader;
063import org.xml.sax.helpers.DefaultHandler;
064
065/**
066 * Property list file (plist) in XML FORMAT as used by macOS X (http://www.apple.com/DTDs/PropertyList-1.0.dtd). This
067 * configuration doesn't support the binary FORMAT used in OS X 10.4.
068 *
069 * <p>
070 * Example:
071 * </p>
072 *
073 * <pre>
074 * &lt;?xml version="1.0"?&gt;
075 * &lt;!DOCTYPE plist SYSTEM "file://localhost/System/Library/DTDs/PropertyList.dtd"&gt;
076 * &lt;plist version="1.0"&gt;
077 *     &lt;dict&gt;
078 *         &lt;key&gt;string&lt;/key&gt;
079 *         &lt;string&gt;value1&lt;/string&gt;
080 *
081 *         &lt;key&gt;integer&lt;/key&gt;
082 *         &lt;integer&gt;12345&lt;/integer&gt;
083 *
084 *         &lt;key&gt;real&lt;/key&gt;
085 *         &lt;real&gt;-123.45E-1&lt;/real&gt;
086 *
087 *         &lt;key&gt;boolean&lt;/key&gt;
088 *         &lt;true/&gt;
089 *
090 *         &lt;key&gt;date&lt;/key&gt;
091 *         &lt;date&gt;2005-01-01T12:00:00Z&lt;/date&gt;
092 *
093 *         &lt;key&gt;data&lt;/key&gt;
094 *         &lt;data&gt;RHJhY28gRG9ybWllbnMgTnVucXVhbSBUaXRpbGxhbmR1cw==&lt;/data&gt;
095 *
096 *         &lt;key&gt;array&lt;/key&gt;
097 *         &lt;array&gt;
098 *             &lt;string&gt;value1&lt;/string&gt;
099 *             &lt;string&gt;value2&lt;/string&gt;
100 *             &lt;string&gt;value3&lt;/string&gt;
101 *         &lt;/array&gt;
102 *
103 *         &lt;key&gt;dictionnary&lt;/key&gt;
104 *         &lt;dict&gt;
105 *             &lt;key&gt;key1&lt;/key&gt;
106 *             &lt;string&gt;value1&lt;/string&gt;
107 *             &lt;key&gt;key2&lt;/key&gt;
108 *             &lt;string&gt;value2&lt;/string&gt;
109 *             &lt;key&gt;key3&lt;/key&gt;
110 *             &lt;string&gt;value3&lt;/string&gt;
111 *         &lt;/dict&gt;
112 *
113 *         &lt;key&gt;nested&lt;/key&gt;
114 *         &lt;dict&gt;
115 *             &lt;key&gt;node1&lt;/key&gt;
116 *             &lt;dict&gt;
117 *                 &lt;key&gt;node2&lt;/key&gt;
118 *                 &lt;dict&gt;
119 *                     &lt;key&gt;node3&lt;/key&gt;
120 *                     &lt;string&gt;value&lt;/string&gt;
121 *                 &lt;/dict&gt;
122 *             &lt;/dict&gt;
123 *         &lt;/dict&gt;
124 *
125 *     &lt;/dict&gt;
126 * &lt;/plist&gt;
127 * </pre>
128 *
129 * @since 1.2
130 */
131public class XMLPropertyListConfiguration extends BaseHierarchicalConfiguration implements FileBasedConfiguration, FileLocatorAware {
132
133    /**
134     * Container for array elements. <strong>Do not use this class !</strong> It is used internally by XMLPropertyConfiguration to
135     * parse the configuration file, it may be removed at any moment in the future.
136     */
137    private static final class ArrayNodeBuilder extends PListNodeBuilder {
138
139        /** The list of values in the array. */
140        private final List<Object> list = new ArrayList<>();
141
142        /**
143         * Add an object to the array.
144         *
145         * @param value the value to be added
146         */
147        @Override
148        public void addValue(final Object value) {
149            list.add(value);
150        }
151
152        /**
153         * Return the list of values in the array.
154         *
155         * @return the {@link List} of values
156         */
157        @Override
158        protected Object getNodeValue() {
159            return list;
160        }
161    }
162
163    /**
164     * A specialized builder class with addXXX methods to parse the typed data passed by the SAX handler. It is used for
165     * creating the nodes of the configuration.
166     */
167    private static class PListNodeBuilder {
168
169        /**
170         * The MacOS FORMAT of dates in plist files. Note: Because {@code SimpleDateFormat} is not thread-safe, each access has
171         * to be synchronized.
172         */
173        private static final DateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
174        static {
175            FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
176        }
177
178        /**
179         * The GNUstep FORMAT of dates in plist files. Note: Because {@code SimpleDateFormat} is not thread-safe, each access
180         * has to be synchronized.
181         */
182        private static final DateFormat GNUSTEP_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z");
183
184        /** A collection with child builders of this builder. */
185        private final Collection<PListNodeBuilder> childBuilders = new LinkedList<>();
186
187        /** The name of the represented node. */
188        private String name;
189
190        /** The current value of the represented node. */
191        private Object value;
192
193        /**
194         * Adds the given child builder to this builder.
195         *
196         * @param child the child builder to be added
197         */
198        public void addChild(final PListNodeBuilder child) {
199            childBuilders.add(child);
200        }
201
202        /**
203         * Parse the specified string as a byte array in base 64 FORMAT and add it to the values of the node.
204         *
205         * @param value the value to be added
206         */
207        public void addDataValue(final String value) {
208            addValue(Base64.getMimeDecoder().decode(value.getBytes(DATA_ENCODING)));
209        }
210
211        /**
212         * Parse the specified string as a date and add it to the values of the node.
213         *
214         * @param value the value to be added
215         * @throws IllegalArgumentException if the date string cannot be parsed
216         */
217        public void addDateValue(final String value) {
218            try {
219                if (value.indexOf(' ') != -1) {
220                    // parse the date using the GNUstep FORMAT
221                    synchronized (GNUSTEP_FORMAT) {
222                        addValue(GNUSTEP_FORMAT.parse(value));
223                    }
224                } else {
225                    // parse the date using the MacOS X FORMAT
226                    synchronized (FORMAT) {
227                        addValue(FORMAT.parse(value));
228                    }
229                }
230            } catch (final ParseException e) {
231                throw new IllegalArgumentException(String.format("'%s' cannot be parsed to a date!", value), e);
232            }
233        }
234
235        /**
236         * Add a boolean value 'false' to the values of the node.
237         */
238        public void addFalseValue() {
239            addValue(Boolean.FALSE);
240        }
241
242        /**
243         * Parse the specified string as an Interger and add it to the values of the node.
244         *
245         * @param value the value to be added
246         */
247        public void addIntegerValue(final String value) {
248            addValue(new BigInteger(value));
249        }
250
251        /**
252         * Add a sublist to the values of the node.
253         *
254         * @param node the node whose value will be added to the current node value
255         */
256        public void addList(final ArrayNodeBuilder node) {
257            addValue(node.getNodeValue());
258        }
259
260        /**
261         * Parse the specified string as a Double and add it to the values of the node.
262         *
263         * @param value the value to be added
264         */
265        public void addRealValue(final String value) {
266            addValue(new BigDecimal(value));
267        }
268
269        /**
270         * Add a boolean value 'true' to the values of the node.
271         */
272        public void addTrueValue() {
273            addValue(Boolean.TRUE);
274        }
275
276        /**
277         * Update the value of the node. If the existing value is null, it's replaced with the new value. If the existing value
278         * is a list, the specified value is appended to the list. If the existing value is not null, a list with the two values
279         * is built.
280         *
281         * @param v the value to be added
282         */
283        public void addValue(final Object v) {
284            if (value == null) {
285                value = v;
286            } else if (value instanceof Collection) {
287                // This is safe because we create the collections ourselves
288                @SuppressWarnings("unchecked")
289                final Collection<Object> collection = (Collection<Object>) value;
290                collection.add(v);
291            } else {
292                final List<Object> list = new ArrayList<>();
293                list.add(value);
294                list.add(v);
295                value = list;
296            }
297        }
298
299        /**
300         * Creates the configuration node defined by this builder.
301         *
302         * @return the newly created configuration node
303         */
304        public ImmutableNode createNode() {
305            final ImmutableNode.Builder nodeBuilder = new ImmutableNode.Builder(childBuilders.size());
306            childBuilders.forEach(child -> nodeBuilder.addChild(child.createNode()));
307            return nodeBuilder.name(name).value(getNodeValue()).create();
308        }
309
310        /**
311         * Gets the final value for the node to be created. This method is called when the represented configuration node is
312         * actually created.
313         *
314         * @return the value of the resulting configuration node
315         */
316        protected Object getNodeValue() {
317            return value;
318        }
319
320        /**
321         * Sets the name of the represented node.
322         *
323         * @param nodeName the node name
324         */
325        public void setName(final String nodeName) {
326            name = nodeName;
327        }
328    }
329
330    /**
331     * SAX Handler to build the configuration nodes while the document is being parsed.
332     */
333    private final class XMLPropertyListHandler extends DefaultHandler {
334
335        /** The buffer containing the text node being read */
336        private final StringBuilder buffer = new StringBuilder();
337
338        /** The stack of configuration nodes */
339        private final List<PListNodeBuilder> stack = new ArrayList<>();
340
341        /** The builder for the resulting node. */
342        private final PListNodeBuilder resultBuilder;
343
344        public XMLPropertyListHandler() {
345            resultBuilder = new PListNodeBuilder();
346            push(resultBuilder);
347        }
348
349        @Override
350        public void characters(final char[] ch, final int start, final int length) throws SAXException {
351            buffer.append(ch, start, length);
352        }
353
354        @Override
355        public void endElement(final String uri, final String localName, final String qName) throws SAXException {
356            if ("key".equals(qName)) {
357                // create a new node, link it to its parent and push it on the stack
358                final PListNodeBuilder node = new PListNodeBuilder();
359                node.setName(buffer.toString());
360                peekNE().addChild(node);
361                push(node);
362            } else if ("dict".equals(qName)) {
363                // remove the root of the XMLPropertyListConfiguration previously pushed on the stack
364                final PListNodeBuilder builder = pop();
365                assert builder != null : "Stack was empty!";
366                if (peek() instanceof ArrayNodeBuilder) {
367                    // create the configuration
368                    final XMLPropertyListConfiguration config = new XMLPropertyListConfiguration(builder.createNode());
369
370                    // add it to the ArrayNodeBuilder
371                    final ArrayNodeBuilder node = (ArrayNodeBuilder) peekNE();
372                    node.addValue(config);
373                }
374            } else {
375                switch (qName) {
376                case "string":
377                    peekNE().addValue(buffer.toString());
378                    break;
379                case "integer":
380                    peekNE().addIntegerValue(buffer.toString());
381                    break;
382                case "real":
383                    peekNE().addRealValue(buffer.toString());
384                    break;
385                case "true":
386                    peekNE().addTrueValue();
387                    break;
388                case "false":
389                    peekNE().addFalseValue();
390                    break;
391                case "data":
392                    peekNE().addDataValue(buffer.toString());
393                    break;
394                case "date":
395                    try {
396                        peekNE().addDateValue(buffer.toString());
397                    } catch (final IllegalArgumentException iex) {
398                        getLogger().warn("Ignoring invalid date property " + buffer);
399                    }
400                    break;
401                case "array": {
402                    final ArrayNodeBuilder array = (ArrayNodeBuilder) pop();
403                    peekNE().addList(array);
404                    break;
405                }
406                default:
407                    break;
408                }
409
410                // remove the plist node on the stack once the value has been parsed,
411                // array nodes remains on the stack for the next values in the list
412                if (!(peek() instanceof ArrayNodeBuilder)) {
413                    pop();
414                }
415            }
416
417            buffer.setLength(0);
418        }
419
420        /**
421         * Gets the builder for the result node.
422         *
423         * @return the result node builder
424         */
425        public PListNodeBuilder getResultBuilder() {
426            return resultBuilder;
427        }
428
429        /**
430         * Return the node on the top of the stack.
431         */
432        private PListNodeBuilder peek() {
433            if (!stack.isEmpty()) {
434                return stack.get(stack.size() - 1);
435            }
436            return null;
437        }
438
439        /**
440         * Returns the node on top of the non-empty stack. Throws an exception if the stack is empty.
441         *
442         * @return the top node of the stack
443         * @throws ConfigurationRuntimeException if the stack is empty
444         */
445        private PListNodeBuilder peekNE() {
446            final PListNodeBuilder result = peek();
447            if (result == null) {
448                throw new ConfigurationRuntimeException("Access to empty stack!");
449            }
450            return result;
451        }
452
453        /**
454         * Remove and return the node on the top of the stack.
455         */
456        private PListNodeBuilder pop() {
457            if (!stack.isEmpty()) {
458                return stack.remove(stack.size() - 1);
459            }
460            return null;
461        }
462
463        /**
464         * Put a node on the top of the stack.
465         */
466        private void push(final PListNodeBuilder node) {
467            stack.add(node);
468        }
469
470        @Override
471        public void startElement(final String uri, final String localName, final String qName, final Attributes attributes) throws SAXException {
472            if ("array".equals(qName)) {
473                push(new ArrayNodeBuilder());
474            } else if ("dict".equals(qName) && peek() instanceof ArrayNodeBuilder) {
475                // push the new root builder on the stack
476                push(new PListNodeBuilder());
477            }
478        }
479    }
480
481    /** Size of the indentation for the generated file. */
482    private static final int INDENT_SIZE = 4;
483
484    /** Constant for the encoding for binary data. */
485    private static final Charset DATA_ENCODING = StandardCharsets.UTF_8;
486
487    /**
488     * Transform a map of arbitrary types into a map with string keys and object values. All keys of the source map which
489     * are not of type String are dropped.
490     *
491     * @param src the map to be converted
492     * @return the resulting map
493     */
494    private static Map<String, Object> transformMap(final Map<?, ?> src) {
495        final Map<String, Object> dest = new HashMap<>();
496        for (final Map.Entry<?, ?> e : src.entrySet()) {
497            if (e.getKey() instanceof String) {
498                dest.put((String) e.getKey(), e.getValue());
499            }
500        }
501        return dest;
502    }
503
504    /** Temporarily stores the current file location. */
505    private FileLocator locator;
506
507    /**
508     * Creates an empty XMLPropertyListConfiguration object which can be used to synthesize a new plist file by adding
509     * values and then saving().
510     */
511    public XMLPropertyListConfiguration() {
512    }
513
514    /**
515     * Creates a new instance of {@code XMLPropertyListConfiguration} and copies the content of the specified configuration
516     * into this object.
517     *
518     * @param configuration the configuration to copy
519     * @since 1.4
520     */
521    public XMLPropertyListConfiguration(final HierarchicalConfiguration<ImmutableNode> configuration) {
522        super(configuration);
523    }
524
525    /**
526     * Creates a new instance of {@code XMLPropertyConfiguration} with the given root node.
527     *
528     * @param root the root node
529     */
530    XMLPropertyListConfiguration(final ImmutableNode root) {
531        super(new InMemoryNodeModel(root));
532    }
533
534    @Override
535    protected void addPropertyInternal(final String key, final Object value) {
536        if (value instanceof byte[] || value instanceof List) {
537            addPropertyDirect(key, value);
538        } else if (value instanceof Object[]) {
539            addPropertyDirect(key, Arrays.asList((Object[]) value));
540        } else {
541            super.addPropertyInternal(key, value);
542        }
543    }
544
545    /**
546     * Stores the current file locator. This method is called before I/O operations.
547     *
548     * @param locator the current {@code FileLocator}
549     */
550    @Override
551    public void initFileLocator(final FileLocator locator) {
552        this.locator = locator;
553    }
554
555    /**
556     * Append a node to the writer, indented according to a specific level.
557     */
558    private void printNode(final PrintWriter out, final int indentLevel, final ImmutableNode node) {
559        final String padding = StringUtils.repeat(" ", indentLevel * INDENT_SIZE);
560
561        if (node.getNodeName() != null) {
562            out.println(padding + "<key>" + StringEscapeUtils.escapeXml10(node.getNodeName()) + "</key>");
563        }
564
565        final List<ImmutableNode> children = node.getChildren();
566        if (!children.isEmpty()) {
567            out.println(padding + "<dict>");
568
569            final Iterator<ImmutableNode> it = children.iterator();
570            while (it.hasNext()) {
571                final ImmutableNode child = it.next();
572                printNode(out, indentLevel + 1, child);
573
574                if (it.hasNext()) {
575                    out.println();
576                }
577            }
578
579            out.println(padding + "</dict>");
580        } else if (node.getValue() == null) {
581            out.println(padding + "<dict/>");
582        } else {
583            final Object value = node.getValue();
584            printValue(out, indentLevel, value);
585        }
586    }
587
588    /**
589     * Append a value to the writer, indented according to a specific level.
590     */
591    private void printValue(final PrintWriter out, final int indentLevel, final Object value) {
592        final String padding = StringUtils.repeat(" ", indentLevel * INDENT_SIZE);
593
594        if (value instanceof Date) {
595            synchronized (PListNodeBuilder.FORMAT) {
596                out.println(padding + "<date>" + PListNodeBuilder.FORMAT.format((Date) value) + "</date>");
597            }
598        } else if (value instanceof Calendar) {
599            printValue(out, indentLevel, ((Calendar) value).getTime());
600        } else if (value instanceof Number) {
601            if (value instanceof Double || value instanceof Float || value instanceof BigDecimal) {
602                out.println(padding + "<real>" + value.toString() + "</real>");
603            } else {
604                out.println(padding + "<integer>" + value.toString() + "</integer>");
605            }
606        } else if (value instanceof Boolean) {
607            if (((Boolean) value).booleanValue()) {
608                out.println(padding + "<true/>");
609            } else {
610                out.println(padding + "<false/>");
611            }
612        } else if (value instanceof List) {
613            out.println(padding + "<array>");
614            ((List<?>) value).forEach(o -> printValue(out, indentLevel + 1, o));
615            out.println(padding + "</array>");
616        } else if (value instanceof HierarchicalConfiguration) {
617            // This is safe because we have created this configuration
618            @SuppressWarnings("unchecked")
619            final HierarchicalConfiguration<ImmutableNode> config = (HierarchicalConfiguration<ImmutableNode>) value;
620            printNode(out, indentLevel, config.getNodeModel().getNodeHandler().getRootNode());
621        } else if (value instanceof ImmutableConfiguration) {
622            // display a flat Configuration as a dictionary
623            out.println(padding + "<dict>");
624            final ImmutableConfiguration config = (ImmutableConfiguration) value;
625            config.forEach((k, v) -> {
626                // create a node for each property
627                final ImmutableNode node = new ImmutableNode.Builder().name(k).value(v).create();
628                // print the node
629                printNode(out, indentLevel + 1, node);
630                out.println();
631            });
632            out.println(padding + "</dict>");
633        } else if (value instanceof Map) {
634            // display a Map as a dictionary
635            final Map<String, Object> map = transformMap((Map<?, ?>) value);
636            printValue(out, indentLevel, new MapConfiguration(map));
637        } else if (value instanceof byte[]) {
638            final String base64 = new String(Base64.getMimeEncoder().encode((byte[]) value), DATA_ENCODING);
639            out.println(padding + "<data>" + StringEscapeUtils.escapeXml10(base64) + "</data>");
640        } else if (value != null) {
641            out.println(padding + "<string>" + StringEscapeUtils.escapeXml10(String.valueOf(value)) + "</string>");
642        } else {
643            out.println(padding + "<string/>");
644        }
645    }
646
647    @Override
648    public void read(final Reader in) throws ConfigurationException {
649        // set up the DTD validation
650        final EntityResolver resolver = (publicId, systemId) -> new InputSource(getClass().getClassLoader().getResourceAsStream("PropertyList-1.0.dtd"));
651        // parse the file
652        final XMLPropertyListHandler handler = new XMLPropertyListHandler();
653        try {
654            final SAXParserFactory factory = SAXParserFactory.newInstance();
655            factory.setValidating(true);
656            final XMLReader xmlReader = factory.newSAXParser().getXMLReader();
657            xmlReader.setEntityResolver(resolver);
658            xmlReader.setContentHandler(handler);
659            xmlReader.parse(new InputSource(in));
660            getNodeModel().mergeRoot(handler.getResultBuilder().createNode(), null, null, null, this);
661        } catch (final Exception e) {
662            throw new ConfigurationException("Unable to parse the configuration file", e);
663        }
664    }
665
666    private void setPropertyDirect(final String key, final Object value) {
667        setDetailEvents(false);
668        try {
669            clearProperty(key);
670            addPropertyDirect(key, value);
671        } finally {
672            setDetailEvents(true);
673        }
674    }
675
676    @Override
677    protected void setPropertyInternal(final String key, final Object value) {
678        // special case for byte arrays, they must be stored as is in the configuration
679        if (value instanceof byte[] || value instanceof List) {
680            setPropertyDirect(key, value);
681        } else if (value instanceof Object[]) {
682            setPropertyDirect(key, Arrays.asList((Object[]) value));
683        } else {
684            super.setPropertyInternal(key, value);
685        }
686    }
687
688    @Override
689    public void write(final Writer out) throws ConfigurationException {
690        if (locator == null) {
691            throw new ConfigurationException(
692                    "Save operation not properly initialized! Do not call write(Writer) directly, but use a FileHandler to save a configuration.");
693        }
694        final PrintWriter writer = new PrintWriter(out);
695        if (locator.getEncoding() != null) {
696            writer.println("<?xml version=\"1.0\" encoding=\"" + locator.getEncoding() + "\"?>");
697        } else {
698            writer.println("<?xml version=\"1.0\"?>");
699        }
700        writer.println("<!DOCTYPE plist SYSTEM \"file://localhost/System/Library/DTDs/PropertyList.dtd\">");
701        writer.println("<plist version=\"1.0\">");
702        printNode(writer, 1, getNodeModel().getNodeHandler().getRootNode());
703        writer.println("</plist>");
704        writer.flush();
705    }
706}