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