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