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