View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *     https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.configuration2.plist;
19  
20  import java.io.PrintWriter;
21  import java.io.Reader;
22  import java.io.Writer;
23  import java.util.ArrayList;
24  import java.util.Calendar;
25  import java.util.Date;
26  import java.util.HashMap;
27  import java.util.Iterator;
28  import java.util.List;
29  import java.util.Map;
30  import java.util.TimeZone;
31  
32  import org.apache.commons.codec.binary.Hex;
33  import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
34  import org.apache.commons.configuration2.Configuration;
35  import org.apache.commons.configuration2.FileBasedConfiguration;
36  import org.apache.commons.configuration2.HierarchicalConfiguration;
37  import org.apache.commons.configuration2.ImmutableConfiguration;
38  import org.apache.commons.configuration2.MapConfiguration;
39  import org.apache.commons.configuration2.ex.ConfigurationException;
40  import org.apache.commons.configuration2.tree.ImmutableNode;
41  import org.apache.commons.configuration2.tree.InMemoryNodeModel;
42  import org.apache.commons.configuration2.tree.NodeHandler;
43  import org.apache.commons.lang3.StringUtils;
44  
45  /**
46   * NeXT / OpenStep style configuration. This configuration can read and write ASCII plist files. It supports the GNUStep
47   * extension to specify date objects.
48   * <p>
49   * References:
50   * <ul>
51   * <li><a href=
52   * "https://developer.apple.com/documentation/Cocoa/Conceptual/PropertyLists/OldStylePlists/OldStylePLists.html"> Apple
53   * Documentation - Old-Style ASCII Property Lists</a></li>
54   * <li><a href="https://www.gnustep.org/resources/documentation/Developer/Base/Reference/NSPropertyList.html"> GNUStep
55   * Documentation</a></li>
56   * </ul>
57   *
58   * <p>
59   * Example:
60   * </p>
61   *
62   * <pre>
63   * {
64   *     foo = "bar";
65   *
66   *     array = ( value1, value2, value3 );
67   *
68   *     data = &lt;4f3e0145ab&gt;;
69   *
70   *     date = &lt;*D2007-05-05 20:05:00 +0100&gt;;
71   *
72   *     nested =
73   *     {
74   *         key1 = value1;
75   *         key2 = value;
76   *         nested =
77   *         {
78   *             foo = bar
79   *         }
80   *     }
81   * }
82   * </pre>
83   *
84   * @since 1.2
85   */
86  public class PropertyListConfiguration extends BaseHierarchicalConfiguration implements FileBasedConfiguration {
87      /**
88       * A helper class for parsing and formatting date literals. Usually we would use {@code SimpleDateFormat} for this
89       * purpose, but in Java 1.3 the functionality of this class is limited. So we have a hierarchy of parser classes instead
90       * that deal with the different components of a date literal.
91       */
92      private abstract static class DateComponentParser {
93          /**
94           * Checks whether the given string has at least {@code length} characters starting from the given parsing position. If
95           * this is not the case, an exception will be thrown.
96           *
97           * @param s the string to be tested
98           * @param index the current index
99           * @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         if (value instanceof List) {
453             out.print("( ");
454             final Iterator<?> it = ((List<?>) value).iterator();
455             while (it.hasNext()) {
456                 printValue(out, indentLevel + 1, it.next());
457                 if (it.hasNext()) {
458                     out.print(", ");
459                 }
460             }
461             out.print(" )");
462         } else if (value instanceof PropertyListConfiguration) {
463             final NodeHandler<ImmutableNode> handler = ((PropertyListConfiguration) value).getModel().getNodeHandler();
464             printNode(out, indentLevel, handler.getRootNode(), handler);
465         } else if (value instanceof ImmutableConfiguration) {
466             // display a flat Configuration as a dictionary
467             out.println();
468             out.println(padding + "{");
469             final ImmutableConfiguration config = (ImmutableConfiguration) value;
470             config.forEach((k, v) -> {
471                 final ImmutableNode node = new ImmutableNode.Builder().name(k).value(v).create();
472                 final InMemoryNodeModel tempModel = new InMemoryNodeModel(node);
473                 printNode(out, indentLevel + 1, node, tempModel.getNodeHandler());
474                 out.println(";");
475             });
476             out.println(padding + "}");
477         } else if (value instanceof Map) {
478             // display a Map as a dictionary
479             final Map<String, Object> map = transformMap((Map<?, ?>) value);
480             printValue(out, indentLevel, new MapConfiguration(map));
481         } else if (value instanceof byte[]) {
482             out.print("<" + new String(Hex.encodeHex((byte[]) value)) + ">");
483         } else if (value instanceof Date) {
484             out.print(formatDate((Date) value));
485         } else if (value != null) {
486             out.print(quoteString(String.valueOf(value)));
487         }
488     }
489 
490     /**
491      * Quote the specified string if necessary, that's if the string contains:
492      * <ul>
493      * <li>a space character (' ', '\t', '\r', '\n')</li>
494      * <li>a quote '"'</li>
495      * <li>special characters in plist files ('(', ')', '{', '}', '=', ';', ',')</li>
496      * </ul>
497      * Quotes within the string are escaped.
498      *
499      * <p>
500      * Examples:
501      * </p>
502      * <ul>
503      * <li>abcd -> abcd</li>
504      * <li>ab cd -> "ab cd"</li>
505      * <li>foo"bar -> "foo\"bar"</li>
506      * <li>foo;bar -> "foo;bar"</li>
507      * </ul>
508      */
509     String quoteString(String s) {
510         if (s == null) {
511             return null;
512         }
513 
514         if (s.indexOf(' ') != -1 || s.indexOf('\t') != -1 || s.indexOf('\r') != -1 || s.indexOf('\n') != -1 || s.indexOf('"') != -1 || s.indexOf('(') != -1
515             || s.indexOf(')') != -1 || s.indexOf('{') != -1 || s.indexOf('}') != -1 || s.indexOf('=') != -1 || s.indexOf(',') != -1 || s.indexOf(';') != -1) {
516             s = s.replace("\"", "\\\"");
517             s = "\"" + s + "\"";
518         }
519 
520         return s;
521     }
522 
523     @Override
524     public void read(final Reader in) throws ConfigurationException {
525         final PropertyListParser parser = new PropertyListParser(in);
526         try {
527             final PropertyListConfiguration config = parser.parse();
528             getModel().setRootNode(config.getNodeModel().getNodeHandler().getRootNode());
529         } catch (final ParseException e) {
530             throw new ConfigurationException(e);
531         }
532     }
533 
534     @Override
535     protected void setPropertyInternal(final String key, final Object value) {
536         // special case for byte arrays, they must be stored as is in the configuration
537         if (value instanceof byte[]) {
538             setDetailEvents(false);
539             try {
540                 clearProperty(key);
541                 addPropertyDirect(key, value);
542             } finally {
543                 setDetailEvents(true);
544             }
545         } else {
546             super.setPropertyInternal(key, value);
547         }
548     }
549 
550     @Override
551     public void write(final Writer out) throws ConfigurationException {
552         final PrintWriter writer = new PrintWriter(out);
553         final NodeHandler<ImmutableNode> handler = getModel().getNodeHandler();
554         printNode(writer, 0, handler.getRootNode(), handler);
555         writer.flush();
556     }
557 }