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.plist;
019
020import java.io.PrintWriter;
021import java.io.Reader;
022import java.io.Writer;
023import java.util.ArrayList;
024import java.util.Calendar;
025import java.util.Date;
026import java.util.HashMap;
027import java.util.Iterator;
028import java.util.List;
029import java.util.Map;
030import java.util.TimeZone;
031
032import org.apache.commons.codec.binary.Hex;
033import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
034import org.apache.commons.configuration2.Configuration;
035import org.apache.commons.configuration2.FileBasedConfiguration;
036import org.apache.commons.configuration2.HierarchicalConfiguration;
037import org.apache.commons.configuration2.ImmutableConfiguration;
038import org.apache.commons.configuration2.MapConfiguration;
039import org.apache.commons.configuration2.ex.ConfigurationException;
040import org.apache.commons.configuration2.tree.ImmutableNode;
041import org.apache.commons.configuration2.tree.InMemoryNodeModel;
042import org.apache.commons.configuration2.tree.NodeHandler;
043import org.apache.commons.lang3.StringUtils;
044
045/**
046 * NeXT / OpenStep style configuration. This configuration can read and write ASCII plist files. It supports the GNUStep
047 * extension to specify date objects.
048 * <p>
049 * References:
050 * <ul>
051 * <li><a href=
052 * "https://developer.apple.com/documentation/Cocoa/Conceptual/PropertyLists/OldStylePlists/OldStylePLists.html"> Apple
053 * Documentation - Old-Style ASCII Property Lists</a></li>
054 * <li><a href="http://www.gnustep.org/resources/documentation/Developer/Base/Reference/NSPropertyList.html"> GNUStep
055 * Documentation</a></li>
056 * </ul>
057 *
058 * <p>
059 * Example:
060 * </p>
061 *
062 * <pre>
063 * {
064 *     foo = "bar";
065 *
066 *     array = ( value1, value2, value3 );
067 *
068 *     data = &lt;4f3e0145ab&gt;;
069 *
070 *     date = &lt;*D2007-05-05 20:05:00 +0100&gt;;
071 *
072 *     nested =
073 *     {
074 *         key1 = value1;
075 *         key2 = value;
076 *         nested =
077 *         {
078 *             foo = bar
079 *         }
080 *     }
081 * }
082 * </pre>
083 *
084 * @since 1.2
085 */
086public class PropertyListConfiguration extends BaseHierarchicalConfiguration implements FileBasedConfiguration {
087    /** Constant for the separator parser for the date part. */
088    private static final DateComponentParser DATE_SEPARATOR_PARSER = new DateSeparatorParser("-");
089
090    /** Constant for the separator parser for the time part. */
091    private static final DateComponentParser TIME_SEPARATOR_PARSER = new DateSeparatorParser(":");
092
093    /** Constant for the separator parser for blanks between the parts. */
094    private static final DateComponentParser BLANK_SEPARATOR_PARSER = new DateSeparatorParser(" ");
095
096    /** An array with the component parsers for dealing with dates. */
097    private static final DateComponentParser[] DATE_PARSERS = {new DateSeparatorParser("<*D"), new DateFieldParser(Calendar.YEAR, 4), DATE_SEPARATOR_PARSER,
098        new DateFieldParser(Calendar.MONTH, 2, 1), DATE_SEPARATOR_PARSER, new DateFieldParser(Calendar.DATE, 2), BLANK_SEPARATOR_PARSER,
099        new DateFieldParser(Calendar.HOUR_OF_DAY, 2), TIME_SEPARATOR_PARSER, new DateFieldParser(Calendar.MINUTE, 2), TIME_SEPARATOR_PARSER,
100        new DateFieldParser(Calendar.SECOND, 2), BLANK_SEPARATOR_PARSER, new DateTimeZoneParser(), new DateSeparatorParser(">")};
101
102    /** Constant for the ID prefix for GMT time zones. */
103    private static final String TIME_ZONE_PREFIX = "GMT";
104
105    /** Constant for the milliseconds of a minute. */
106    private static final int MILLIS_PER_MINUTE = 1000 * 60;
107
108    /** Constant for the minutes per hour. */
109    private static final int MINUTES_PER_HOUR = 60;
110
111    /** Size of the indentation for the generated file. */
112    private static final int INDENT_SIZE = 4;
113
114    /** Constant for the length of a time zone. */
115    private static final int TIME_ZONE_LENGTH = 5;
116
117    /** Constant for the padding character in the date format. */
118    private static final char PAD_CHAR = '0';
119
120    /**
121     * Creates an empty PropertyListConfiguration object which can be used to synthesize a new plist file by adding values
122     * and then saving().
123     */
124    public PropertyListConfiguration() {
125    }
126
127    /**
128     * Creates a new instance of {@code PropertyListConfiguration} and copies the content of the specified configuration
129     * into this object.
130     *
131     * @param c the configuration to copy
132     * @since 1.4
133     */
134    public PropertyListConfiguration(final HierarchicalConfiguration<ImmutableNode> c) {
135        super(c);
136    }
137
138    /**
139     * Creates a new instance of {@code PropertyListConfiguration} with the given root node.
140     *
141     * @param root the root node
142     */
143    PropertyListConfiguration(final ImmutableNode root) {
144        super(new InMemoryNodeModel(root));
145    }
146
147    @Override
148    protected void setPropertyInternal(final String key, final Object value) {
149        // special case for byte arrays, they must be stored as is in the configuration
150        if (value instanceof byte[]) {
151            setDetailEvents(false);
152            try {
153                clearProperty(key);
154                addPropertyDirect(key, value);
155            } finally {
156                setDetailEvents(true);
157            }
158        } else {
159            super.setPropertyInternal(key, value);
160        }
161    }
162
163    @Override
164    protected void addPropertyInternal(final String key, final Object value) {
165        if (value instanceof byte[]) {
166            addPropertyDirect(key, value);
167        } else {
168            super.addPropertyInternal(key, value);
169        }
170    }
171
172    @Override
173    public void read(final Reader in) throws ConfigurationException {
174        final PropertyListParser parser = new PropertyListParser(in);
175        try {
176            final PropertyListConfiguration config = parser.parse();
177            getModel().setRootNode(config.getNodeModel().getNodeHandler().getRootNode());
178        } catch (final ParseException e) {
179            throw new ConfigurationException(e);
180        }
181    }
182
183    @Override
184    public void write(final Writer out) throws ConfigurationException {
185        final PrintWriter writer = new PrintWriter(out);
186        final NodeHandler<ImmutableNode> handler = getModel().getNodeHandler();
187        printNode(writer, 0, handler.getRootNode(), handler);
188        writer.flush();
189    }
190
191    /**
192     * Append a node to the writer, indented according to a specific level.
193     */
194    private void printNode(final PrintWriter out, final int indentLevel, final ImmutableNode node, final NodeHandler<ImmutableNode> handler) {
195        final String padding = StringUtils.repeat(" ", indentLevel * INDENT_SIZE);
196
197        if (node.getNodeName() != null) {
198            out.print(padding + quoteString(node.getNodeName()) + " = ");
199        }
200
201        final List<ImmutableNode> children = new ArrayList<>(node.getChildren());
202        if (!children.isEmpty()) {
203            // skip a line, except for the root dictionary
204            if (indentLevel > 0) {
205                out.println();
206            }
207
208            out.println(padding + "{");
209
210            // display the children
211            final Iterator<ImmutableNode> it = children.iterator();
212            while (it.hasNext()) {
213                final ImmutableNode child = it.next();
214
215                printNode(out, indentLevel + 1, child, handler);
216
217                // add a semi colon for elements that are not dictionaries
218                final Object value = child.getValue();
219                if (value != null && !(value instanceof Map) && !(value instanceof Configuration)) {
220                    out.println(";");
221                }
222
223                // skip a line after arrays and dictionaries
224                if (it.hasNext() && (value == null || value instanceof List)) {
225                    out.println();
226                }
227            }
228
229            out.print(padding + "}");
230
231            // line feed if the dictionary is not in an array
232            if (handler.getParent(node) != null) {
233                out.println();
234            }
235        } else if (node.getValue() == null) {
236            out.println();
237            out.print(padding + "{ };");
238
239            // line feed if the dictionary is not in an array
240            if (handler.getParent(node) != null) {
241                out.println();
242            }
243        } else {
244            // display the leaf value
245            final Object value = node.getValue();
246            printValue(out, indentLevel, value);
247        }
248    }
249
250    /**
251     * Append a value to the writer, indented according to a specific level.
252     */
253    private void printValue(final PrintWriter out, final int indentLevel, final Object value) {
254        final String padding = StringUtils.repeat(" ", indentLevel * INDENT_SIZE);
255
256        if (value instanceof List) {
257            out.print("( ");
258            final Iterator<?> it = ((List<?>) value).iterator();
259            while (it.hasNext()) {
260                printValue(out, indentLevel + 1, it.next());
261                if (it.hasNext()) {
262                    out.print(", ");
263                }
264            }
265            out.print(" )");
266        } else if (value instanceof PropertyListConfiguration) {
267            final NodeHandler<ImmutableNode> handler = ((PropertyListConfiguration) value).getModel().getNodeHandler();
268            printNode(out, indentLevel, handler.getRootNode(), handler);
269        } else if (value instanceof ImmutableConfiguration) {
270            // display a flat Configuration as a dictionary
271            out.println();
272            out.println(padding + "{");
273
274            final ImmutableConfiguration config = (ImmutableConfiguration) value;
275            final Iterator<String> it = config.getKeys();
276            while (it.hasNext()) {
277                final String key = it.next();
278                final ImmutableNode node = new ImmutableNode.Builder().name(key).value(config.getProperty(key)).create();
279                final InMemoryNodeModel tempModel = new InMemoryNodeModel(node);
280                printNode(out, indentLevel + 1, node, tempModel.getNodeHandler());
281                out.println(";");
282            }
283            out.println(padding + "}");
284        } else if (value instanceof Map) {
285            // display a Map as a dictionary
286            final Map<String, Object> map = transformMap((Map<?, ?>) value);
287            printValue(out, indentLevel, new MapConfiguration(map));
288        } else if (value instanceof byte[]) {
289            out.print("<" + new String(Hex.encodeHex((byte[]) value)) + ">");
290        } else if (value instanceof Date) {
291            out.print(formatDate((Date) value));
292        } else if (value != null) {
293            out.print(quoteString(String.valueOf(value)));
294        }
295    }
296
297    /**
298     * Quote the specified string if necessary, that's if the string contains:
299     * <ul>
300     * <li>a space character (' ', '\t', '\r', '\n')</li>
301     * <li>a quote '"'</li>
302     * <li>special characters in plist files ('(', ')', '{', '}', '=', ';', ',')</li>
303     * </ul>
304     * Quotes within the string are escaped.
305     *
306     * <p>
307     * Examples:
308     * </p>
309     * <ul>
310     * <li>abcd -> abcd</li>
311     * <li>ab cd -> "ab cd"</li>
312     * <li>foo"bar -> "foo\"bar"</li>
313     * <li>foo;bar -> "foo;bar"</li>
314     * </ul>
315     */
316    String quoteString(String s) {
317        if (s == null) {
318            return null;
319        }
320
321        if (s.indexOf(' ') != -1 || s.indexOf('\t') != -1 || s.indexOf('\r') != -1 || s.indexOf('\n') != -1 || s.indexOf('"') != -1 || s.indexOf('(') != -1
322            || s.indexOf(')') != -1 || s.indexOf('{') != -1 || s.indexOf('}') != -1 || s.indexOf('=') != -1 || s.indexOf(',') != -1 || s.indexOf(';') != -1) {
323            s = s.replace("\"", "\\\"");
324            s = "\"" + s + "\"";
325        }
326
327        return s;
328    }
329
330    /**
331     * Parses a date in a format like {@code <*D2002-03-22 11:30:00 +0100>}.
332     *
333     * @param s the string with the date to be parsed
334     * @return the parsed date
335     * @throws ParseException if an error occurred while parsing the string
336     */
337    static Date parseDate(final String s) throws ParseException {
338        final Calendar cal = Calendar.getInstance();
339        cal.clear();
340        int index = 0;
341
342        for (final DateComponentParser parser : DATE_PARSERS) {
343            index += parser.parseComponent(s, index, cal);
344        }
345
346        return cal.getTime();
347    }
348
349    /**
350     * Returns a string representation for the date specified by the given calendar.
351     *
352     * @param cal the calendar with the initialized date
353     * @return a string for this date
354     */
355    static String formatDate(final Calendar cal) {
356        final StringBuilder buf = new StringBuilder();
357
358        for (final DateComponentParser element : DATE_PARSERS) {
359            element.formatComponent(buf, cal);
360        }
361
362        return buf.toString();
363    }
364
365    /**
366     * Returns a string representation for the specified date.
367     *
368     * @param date the date
369     * @return a string for this date
370     */
371    static String formatDate(final Date date) {
372        final Calendar cal = Calendar.getInstance();
373        cal.setTime(date);
374        return formatDate(cal);
375    }
376
377    /**
378     * Transform a map of arbitrary types into a map with string keys and object values. All keys of the source map which
379     * are not of type String are dropped.
380     *
381     * @param src the map to be converted
382     * @return the resulting map
383     */
384    private static Map<String, Object> transformMap(final Map<?, ?> src) {
385        final Map<String, Object> dest = new HashMap<>();
386        src.forEach((k, v) -> {
387            if (k instanceof String) {
388                dest.put((String) k, v);
389            }
390        });
391        return dest;
392    }
393
394    /**
395     * A helper class for parsing and formatting date literals. Usually we would use {@code SimpleDateFormat} for this
396     * purpose, but in Java 1.3 the functionality of this class is limited. So we have a hierarchy of parser classes instead
397     * that deal with the different components of a date literal.
398     */
399    private abstract static class DateComponentParser {
400        /**
401         * Parses a component from the given input string.
402         *
403         * @param s the string to be parsed
404         * @param index the current parsing position
405         * @param cal the calendar where to store the result
406         * @return the length of the processed component
407         * @throws ParseException if the component cannot be extracted
408         */
409        public abstract int parseComponent(String s, int index, Calendar cal) throws ParseException;
410
411        /**
412         * Formats a date component. This method is used for converting a date in its internal representation into a string
413         * literal.
414         *
415         * @param buf the target buffer
416         * @param cal the calendar with the current date
417         */
418        public abstract void formatComponent(StringBuilder buf, Calendar cal);
419
420        /**
421         * Checks whether the given string has at least {@code length} characters starting from the given parsing position. If
422         * this is not the case, an exception will be thrown.
423         *
424         * @param s the string to be tested
425         * @param index the current index
426         * @param length the minimum length after the index
427         * @throws ParseException if the string is too short
428         */
429        protected void checkLength(final String s, final int index, final int length) throws ParseException {
430            final int len = s == null ? 0 : s.length();
431            if (index + length > len) {
432                throw new ParseException("Input string too short: " + s + ", index: " + index);
433            }
434        }
435
436        /**
437         * Adds a number to the given string buffer and adds leading '0' characters until the given length is reached.
438         *
439         * @param buf the target buffer
440         * @param num the number to add
441         * @param length the required length
442         */
443        protected void padNum(final StringBuilder buf, final int num, final int length) {
444            buf.append(StringUtils.leftPad(String.valueOf(num), length, PAD_CHAR));
445        }
446    }
447
448    /**
449     * A specialized date component parser implementation that deals with numeric calendar fields. The class is able to
450     * extract fields from a string literal and to format a literal from a calendar.
451     */
452    private static final class DateFieldParser extends DateComponentParser {
453        /** Stores the calendar field to be processed. */
454        private final int calendarField;
455
456        /** Stores the length of this field. */
457        private final int length;
458
459        /** An optional offset to add to the calendar field. */
460        private final int offset;
461
462        /**
463         * Creates a new instance of {@code DateFieldParser}.
464         *
465         * @param calFld the calendar field code
466         * @param len the length of this field
467         */
468        public DateFieldParser(final int calFld, final int len) {
469            this(calFld, len, 0);
470        }
471
472        /**
473         * Creates a new instance of {@code DateFieldParser} and fully initializes it.
474         *
475         * @param calFld the calendar field code
476         * @param len the length of this field
477         * @param ofs an offset to add to the calendar field
478         */
479        public DateFieldParser(final int calFld, final int len, final int ofs) {
480            calendarField = calFld;
481            length = len;
482            offset = ofs;
483        }
484
485        @Override
486        public void formatComponent(final StringBuilder buf, final Calendar cal) {
487            padNum(buf, cal.get(calendarField) + offset, length);
488        }
489
490        @Override
491        public int parseComponent(final String s, final int index, final Calendar cal) throws ParseException {
492            checkLength(s, index, length);
493            try {
494                cal.set(calendarField, Integer.parseInt(s.substring(index, index + length)) - offset);
495                return length;
496            } catch (final NumberFormatException nfex) {
497                throw new ParseException("Invalid number: " + s + ", index " + index);
498            }
499        }
500    }
501
502    /**
503     * A specialized date component parser implementation that deals with separator characters.
504     */
505    private static final class DateSeparatorParser extends DateComponentParser {
506        /** Stores the separator. */
507        private final String separator;
508
509        /**
510         * Creates a new instance of {@code DateSeparatorParser} and sets the separator string.
511         *
512         * @param sep the separator string
513         */
514        public DateSeparatorParser(final String sep) {
515            separator = sep;
516        }
517
518        @Override
519        public void formatComponent(final StringBuilder buf, final Calendar cal) {
520            buf.append(separator);
521        }
522
523        @Override
524        public int parseComponent(final String s, final int index, final Calendar cal) throws ParseException {
525            checkLength(s, index, separator.length());
526            if (!s.startsWith(separator, index)) {
527                throw new ParseException("Invalid input: " + s + ", index " + index + ", expected " + separator);
528            }
529            return separator.length();
530        }
531    }
532
533    /**
534     * A specialized date component parser implementation that deals with the time zone part of a date component.
535     */
536    private static final class DateTimeZoneParser extends DateComponentParser {
537        @Override
538        public void formatComponent(final StringBuilder buf, final Calendar cal) {
539            final TimeZone tz = cal.getTimeZone();
540            int ofs = tz.getRawOffset() / MILLIS_PER_MINUTE;
541            if (ofs < 0) {
542                buf.append('-');
543                ofs = -ofs;
544            } else {
545                buf.append('+');
546            }
547            final int hour = ofs / MINUTES_PER_HOUR;
548            final int min = ofs % MINUTES_PER_HOUR;
549            padNum(buf, hour, 2);
550            padNum(buf, min, 2);
551        }
552
553        @Override
554        public int parseComponent(final String s, final int index, final Calendar cal) throws ParseException {
555            checkLength(s, index, TIME_ZONE_LENGTH);
556            final TimeZone tz = TimeZone.getTimeZone(TIME_ZONE_PREFIX + s.substring(index, index + TIME_ZONE_LENGTH));
557            cal.setTimeZone(tz);
558            return TIME_ZONE_LENGTH;
559        }
560    }
561}