001/* 002 * Configurate 003 * Copyright (C) zml and Configurate contributors 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * 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 */ 017package org.spongepowered.configurate.xml; 018 019import org.checkerframework.checker.nullness.qual.NonNull; 020import org.checkerframework.checker.nullness.qual.Nullable; 021import org.spongepowered.configurate.AttributedConfigurationNode; 022import org.spongepowered.configurate.CommentedConfigurationNodeIntermediary; 023import org.spongepowered.configurate.ConfigurateException; 024import org.spongepowered.configurate.ConfigurationNode; 025import org.spongepowered.configurate.ConfigurationOptions; 026import org.spongepowered.configurate.loader.AbstractConfigurationLoader; 027import org.spongepowered.configurate.loader.CommentHandler; 028import org.spongepowered.configurate.loader.CommentHandlers; 029import org.spongepowered.configurate.loader.LoaderOptionSource; 030import org.spongepowered.configurate.loader.ParsingException; 031import org.spongepowered.configurate.util.UnmodifiableCollections; 032import org.w3c.dom.Document; 033import org.w3c.dom.Element; 034import org.w3c.dom.NamedNodeMap; 035import org.w3c.dom.Node; 036import org.w3c.dom.NodeList; 037import org.xml.sax.InputSource; 038import org.xml.sax.SAXException; 039import org.xml.sax.SAXParseException; 040 041import java.io.BufferedReader; 042import java.io.FileNotFoundException; 043import java.io.IOException; 044import java.io.Writer; 045import java.nio.file.NoSuchFileException; 046import java.util.ArrayList; 047import java.util.Collection; 048import java.util.Collections; 049import java.util.LinkedHashMap; 050import java.util.Map; 051import java.util.Objects; 052import java.util.Set; 053import java.util.stream.Collectors; 054 055import javax.xml.XMLConstants; 056import javax.xml.parsers.DocumentBuilder; 057import javax.xml.parsers.DocumentBuilderFactory; 058import javax.xml.parsers.ParserConfigurationException; 059import javax.xml.transform.OutputKeys; 060import javax.xml.transform.Transformer; 061import javax.xml.transform.TransformerConfigurationException; 062import javax.xml.transform.TransformerException; 063import javax.xml.transform.TransformerFactory; 064import javax.xml.transform.dom.DOMSource; 065import javax.xml.transform.stream.StreamResult; 066import javax.xml.validation.Schema; 067 068/** 069 * A loader for XML (Extensible Markup Language), using the native javax library 070 * for parsing and generation. 071 * 072 * @since 4.0.0 073 */ 074public final class XmlConfigurationLoader extends AbstractConfigurationLoader<AttributedConfigurationNode> { 075 076 private static final Set<Class<?>> NATIVE_TYPES = UnmodifiableCollections.toSet(Double.class, Long.class, 077 Integer.class, Boolean.class, String.class, Number.class); 078 079 /** 080 * The prefix of lines within the header. 081 */ 082 private static final String HEADER_PREFIX = "~"; 083 084 private static final String ATTRIBUTE_TYPE = "configurate-type"; 085 086 /** 087 * The user data used to store comments on nodes. 088 */ 089 private static final String USER_DATA_COMMENT = "configurate-comment"; 090 091 /** 092 * The property used to mark how many spaces should be used to indent. 093 */ 094 private static final String INDENT_PROPERTY = "{http://xml.apache.org/xslt}indent-amount"; 095 096 private static final String FEATURE_EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities"; 097 098 private static final String FEATURE_EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities"; 099 100 private static final String FEATURE_LOAD_EXTERNAL_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd"; 101 102 /** 103 * Creates a new {@link XmlConfigurationLoader} builder. 104 * 105 * @return a new builder 106 * @since 4.0.0 107 */ 108 public static @NonNull Builder builder() { 109 return new Builder(); 110 } 111 112 /** 113 * Builds a {@link XmlConfigurationLoader}. 114 * 115 * <p>This builder supports the following options:</p> 116 * <dl> 117 * <dt><prefix>.xml.default-tag-name</dt> 118 * <dd>Equivalent to {@link #defaultTagName(String)}</dd> 119 * <dt><prefix>.xml.indent</dt> 120 * <dd>Equivalent to {@link #indent(int)}</dd> 121 * <dt><prefix>.xml.writes-explicit-type</dt> 122 * <dd>Equivalent to {@link #writesExplicitType(boolean)}</dd> 123 * <dt><prefix>.xml.resolves-external-content</dt> 124 * <dd>Equivalent to {@link #resolvesExternalContent(boolean)}</dd> 125 * <dt><prefix>.xml.includes-xml-declaration</dt> 126 * <dd>Equivalent to {@link #includesXmlDeclaration(boolean)}</dd> 127 * </dl> 128 * 129 * @since 4.0.0 130 */ 131 public static final class Builder extends AbstractConfigurationLoader.Builder<Builder, XmlConfigurationLoader> { 132 private @Nullable Schema schema; 133 private String defaultTagName = "element"; 134 private int indent = 2; 135 private boolean writeExplicitType = true; 136 private boolean resolvesExternalContent; 137 private boolean includeXmlDeclaration = true; 138 139 Builder() { 140 this.from(DEFAULT_OPTIONS_SOURCE); 141 } 142 143 @Override 144 protected void populate(final LoaderOptionSource options) { 145 this.defaultTagName = options.getOr(this.defaultTagName, "xml", "default-tag-name"); 146 this.indent = options.getInt(this.indent, "xml", "indent"); 147 this.writeExplicitType = options.getBoolean(this.writeExplicitType, "xml", "writes-explicit-type"); 148 this.resolvesExternalContent = options.getBoolean(this.resolvesExternalContent, "xml", "resolves-external-content"); 149 this.includeXmlDeclaration = options.getBoolean(this.includeXmlDeclaration, "xml", "includes-xml-declaration"); 150 } 151 152 /** 153 * Sets the level of indentation the resultant loader should use. 154 * 155 * @param indent the indent level 156 * @return this builder (for chaining) 157 * @since 4.0.0 158 */ 159 public @NonNull Builder indent(final int indent) { 160 this.indent = indent; 161 return this; 162 } 163 164 /** 165 * Gets the level of indentation to be used by the resultant loader. 166 * 167 * @return the indent level 168 * @since 4.0.0 169 */ 170 public int indent() { 171 return this.indent; 172 } 173 174 /** 175 * Sets the {@link Schema} the resultant loader should use. 176 * 177 * @param schema the schema 178 * @return this builder (for chaining) 179 * @since 4.0.0 180 */ 181 public Builder schema(final @Nullable Schema schema) { 182 this.schema = schema; 183 return this; 184 } 185 186 /** 187 * Gets the {@link Schema} to be used by the resultant loader. 188 * 189 * @return the schema 190 * @since 4.0.0 191 */ 192 public @Nullable Schema schema() { 193 return this.schema; 194 } 195 196 /** 197 * Sets the default tag name the resultant loader should use. 198 * 199 * @param defaultTagName the default tag name 200 * @return this builder (for chaining) 201 * @since 4.0.0 202 */ 203 public Builder defaultTagName(final String defaultTagName) { 204 this.defaultTagName = defaultTagName; 205 return this; 206 } 207 208 /** 209 * Gets the default tag name to be used by the resultant loader. 210 * 211 * @return the default tag name 212 * @since 4.0.0 213 */ 214 public @NonNull String defaultTagName() { 215 return this.defaultTagName; 216 } 217 218 /** 219 * Sets if the resultant loader should write the explicit type of each 220 * node when saving nodes. 221 * 222 * <p>This is necessary in some cases, as XML has no explicit definition 223 * of an array or list. The loader is able to infer the type in some 224 * cases, but this is inaccurate in some cases, for example lists with 225 * only one element.</p> 226 * 227 * @param writeExplicitType if the loader should write explicit types 228 * @return this builder (for chaining) 229 * @since 4.0.0 230 */ 231 public Builder writesExplicitType(final boolean writeExplicitType) { 232 this.writeExplicitType = writeExplicitType; 233 return this; 234 } 235 236 /** 237 * Gets if explicit type attributes should be written by the loader. 238 * 239 * <p>See the method doc at {@link #writesExplicitType(boolean)} for 240 * a more detailed explanation.</p> 241 * 242 * @return the default tag name 243 * @since 4.0.0 244 */ 245 public boolean writesExplicitType() { 246 return this.writeExplicitType; 247 } 248 249 /** 250 * Sets if the resultant loader should include the XML declaration 251 * header when saving. 252 * 253 * @param includeXmlDeclaration if the XML declaration should be 254 * included 255 * @return this builder (for chaining) 256 * @since 4.0.0 257 */ 258 public Builder includesXmlDeclaration(final boolean includeXmlDeclaration) { 259 this.includeXmlDeclaration = includeXmlDeclaration; 260 return this; 261 } 262 263 /** 264 * Gets if the resultant loader should include the XML declaration 265 * header when saving. 266 * 267 * @return if the XML declaration should be included 268 * @since 4.0.0 269 */ 270 public boolean includesXmlDeclaration() { 271 return this.includeXmlDeclaration; 272 } 273 274 /** 275 * Sets whether external content should be resolved when loading data. 276 * 277 * <p>Resolving this content could result in network requests being 278 * made, and will allow configuration files to access arbitrary URLs 279 * This setting should only be enabled with caution. 280 * 281 * <p>Additionally, through use of features such as entity expansion and 282 * XInclude, documents can be crafted that will grow exponentially 283 * when parsed, requiring an amount of memory to store that may be 284 * greater than what is available for the JVM. 285 * 286 * <p>By default, this is false. 287 * 288 * @param resolvesExternalContent whether to resolve external entities 289 * @return this builder 290 * @since 4.0.0 291 */ 292 public Builder resolvesExternalContent(final boolean resolvesExternalContent) { 293 this.resolvesExternalContent = resolvesExternalContent; 294 return this; 295 } 296 297 /** 298 * Get whether external content should be resolved. 299 * 300 * @return value, defaulting to false 301 * @since 4.0.0 302 */ 303 public boolean resolvesExternalContent() { 304 return this.resolvesExternalContent; 305 } 306 307 @Override 308 public XmlConfigurationLoader build() { 309 this.defaultOptions(o -> o.nativeTypes(NATIVE_TYPES)); 310 return new XmlConfigurationLoader(this); 311 } 312 } 313 314 private final @Nullable Schema schema; 315 private final String defaultTagName; 316 private final int indent; 317 private final boolean writeExplicitType; 318 private final boolean includeXmlDeclaration; 319 private final boolean resolvesExternalContent; 320 321 private XmlConfigurationLoader(final Builder builder) { 322 super(builder, new CommentHandler[] {CommentHandlers.XML_STYLE}); 323 this.schema = builder.schema(); 324 this.defaultTagName = builder.defaultTagName(); 325 this.indent = builder.indent(); 326 this.writeExplicitType = builder.writesExplicitType(); 327 this.includeXmlDeclaration = builder.includesXmlDeclaration(); 328 this.resolvesExternalContent = builder.resolvesExternalContent(); 329 } 330 331 private DocumentBuilder newDocumentBuilder() throws ConfigurateException { 332 final DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); 333 if (this.schema != null) { 334 builderFactory.setSchema(this.schema); 335 } 336 if (!this.resolvesExternalContent) { 337 // Settings based on https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html 338 try { 339 builderFactory.setFeature(FEATURE_EXTERNAL_GENERAL_ENTITIES, false); 340 builderFactory.setFeature(FEATURE_EXTERNAL_PARAMETER_ENTITIES, false); 341 builderFactory.setFeature(FEATURE_LOAD_EXTERNAL_DTD, false); 342 } catch (final ParserConfigurationException e) { 343 throw new ConfigurateException(e); 344 } 345 builderFactory.setXIncludeAware(false); 346 builderFactory.setExpandEntityReferences(false); 347 } 348 349 try { 350 return builderFactory.newDocumentBuilder(); 351 } catch (final ParserConfigurationException e) { 352 throw new ConfigurateException(e); 353 } 354 } 355 356 private Transformer newTransformer() throws ConfigurateException { 357 final TransformerFactory transformerFactory = TransformerFactory.newInstance(); 358 if (!this.resolvesExternalContent) { 359 transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); 360 transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); 361 } 362 try { 363 final Transformer transformer = transformerFactory.newTransformer(); 364 365 // we write the header ourselves. 366 transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); 367 368 if (this.indent > 0) { 369 transformer.setOutputProperty(OutputKeys.INDENT, "yes"); 370 transformer.setOutputProperty(INDENT_PROPERTY, Integer.toString(this.indent)); 371 } 372 return transformer; 373 } catch (final TransformerConfigurationException e) { 374 throw new ConfigurateException(e); 375 } 376 } 377 378 @Override 379 public @NonNull AttributedConfigurationNode load(@NonNull ConfigurationOptions options) throws ParsingException { 380 if (this.source == null) { 381 throw new ParsingException(-1, -1, "", "No source present to read from!", null); 382 } 383 try (BufferedReader reader = this.source.call()) { 384 final DocumentBuilder documentBuilder = this.newDocumentBuilder(); 385 386 final Document document; 387 try { 388 document = documentBuilder.parse(new InputSource(reader)); 389 } catch (final SAXParseException ex) { 390 throw new ParsingException(ex.getLineNumber(), ex.getColumnNumber(), "", ex.getMessage(), ex.getCause()); 391 } catch (final SAXException e) { 392 throw new ParsingException(-1, -1, null, null, e); 393 } 394 395 final NodeList children = document.getChildNodes(); 396 for (int i = 0; i < children.getLength(); ++i) { 397 final Node child = children.item(i); 398 if (child.getNodeType() == Node.COMMENT_NODE) { 399 options = options.header(this.unwrapHeader(child.getTextContent().trim())); 400 } else if (child.getNodeType() == Node.ELEMENT_NODE) { 401 final AttributedConfigurationNode node = this.createNode(options); 402 this.readElement(child, node); 403 return node; 404 } 405 } 406 // empty document, fall through 407 } catch (final FileNotFoundException | NoSuchFileException e) { 408 // Squash -- there's nothing to read 409 } catch (final ParsingException ex) { 410 throw ex; 411 } catch (final Exception e) { 412 throw new ParsingException(-1, -1, "", null, e); 413 } 414 return this.createNode(options); 415 } 416 417 /** 418 * Given a single comment node's comment, clear any prefix lines. 419 * 420 * @param headerContent the content of a header 421 * @return a formatted header, with lines separated by {@link #CONFIGURATE_LINE_SEPARATOR} 422 */ 423 private String unwrapHeader(final String headerContent) { 424 if (headerContent.isEmpty()) { 425 return headerContent; 426 } 427 // TODO: 4.0 may have changed behaviour here when moving away from Guava 428 return CONFIGURATE_LINE_PATTERN.splitAsStream(headerContent) 429 .map(line -> { 430 final String trimmedLine = line.trim(); 431 if (trimmedLine.startsWith(HEADER_PREFIX)) { 432 line = line.substring(line.indexOf(HEADER_PREFIX) + 1); 433 } 434 435 if (line.length() > 0 && line.charAt(0) == ' ') { 436 line = line.substring(1); 437 } 438 return line; 439 }).filter(line -> !line.isEmpty()) 440 .collect(Collectors.joining(CONFIGURATE_LINE_SEPARATOR)); 441 } 442 443 @Override 444 protected void loadInternal(final AttributedConfigurationNode node, final BufferedReader reader) { 445 throw new UnsupportedOperationException("XMLConfigurationLoader provides custom loading logic to handle headers"); 446 } 447 448 private enum NodeType { 449 MAP, LIST 450 } 451 452 private void readElement(final Node from, final AttributedConfigurationNode to) { 453 @Nullable NodeType type = null; 454 455 // copy the name of the tag 456 to.tagName(from.getNodeName()); 457 458 final String potentialComment = (String) from.getUserData(USER_DATA_COMMENT); 459 if (potentialComment != null) { 460 to.comment(potentialComment); 461 } 462 463 // copy attributes 464 if (from.hasAttributes()) { 465 final NamedNodeMap attributes = from.getAttributes(); 466 for (int i = 0; i < attributes.getLength(); i++) { 467 final Node attribute = attributes.item(i); 468 final String key = attribute.getNodeName(); 469 final String value = attribute.getNodeValue(); 470 471 // read the type of the node 472 if (key.equals(ATTRIBUTE_TYPE)) { 473 if (value.equals("map")) { 474 type = NodeType.MAP; 475 } else if (value.equals("list")) { 476 type = NodeType.LIST; 477 } 478 479 // don't add internal configurate attributes to the node 480 continue; 481 } 482 483 to.addAttribute(key, value); 484 } 485 } 486 487 // read out the child nodes into a multimap 488 final Map<String, Collection<Node>> children = new LinkedHashMap<>(); 489 if (from.hasChildNodes()) { 490 final StringBuilder comment = new StringBuilder(); 491 final NodeList childNodes = from.getChildNodes(); 492 for (int i = 0; i < childNodes.getLength(); i++) { 493 final Node child = childNodes.item(i); 494 if (child.getNodeType() == Node.ELEMENT_NODE) { 495 children.computeIfAbsent(child.getNodeName(), $ -> new ArrayList<>()).add(child); 496 if (comment.length() > 0) { 497 child.setUserData(USER_DATA_COMMENT, comment.toString(), null); 498 comment.setLength(0); 499 } 500 } else if (child.getNodeType() == Node.COMMENT_NODE) { 501 if (comment.length() > 0) { 502 comment.append('\n'); 503 } 504 505 comment.append(child.getTextContent().trim()); 506 } 507 } 508 } 509 510 // if there are no child nodes present, assume it's a scalar value 511 if (children.isEmpty()) { 512 to.raw(parseValue(from.getTextContent())); 513 return; 514 } 515 516 // if type is null, we need to infer what type the element is 517 if (type == null) { 518 // if there are no duplicate keys, we can infer that it is a map 519 // otherwise, assume it's a list 520 type = NodeType.MAP; 521 for (final Collection<Node> child : children.values()) { 522 if (child.size() > 1) { 523 type = NodeType.LIST; 524 break; 525 } 526 } 527 } 528 529 if (type == NodeType.MAP) { 530 to.raw(Collections.emptyMap()); 531 } else { 532 to.raw(Collections.emptyList()); 533 } 534 535 // read out the elements 536 for (final Map.Entry<String, Collection<Node>> entry : children.entrySet()) { 537 AttributedConfigurationNode child; 538 if (type == NodeType.MAP) { 539 child = to.node(entry.getKey()); 540 this.readElement(entry.getValue().iterator().next(), child); 541 } else { 542 for (final Node element : entry.getValue()) { 543 child = to.appendListNode(); 544 this.readElement(element, child); 545 } 546 } 547 } 548 } 549 550 @Override 551 protected void writeHeaderInternal(final Writer writer) throws IOException { 552 if (this.includeXmlDeclaration) { 553 writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); 554 writer.write(SYSTEM_LINE_SEPARATOR); 555 } 556 } 557 558 @Override 559 protected void saveInternal(final ConfigurationNode node, final Writer writer) throws ConfigurateException { 560 final DocumentBuilder documentBuilder = this.newDocumentBuilder(); 561 final Document document = documentBuilder.newDocument(); 562 563 final @Nullable Node comment = this.createCommentNode(document, node); 564 if (comment != null) { 565 document.appendChild(comment); 566 } 567 568 document.appendChild(this.writeNode(document, node, null)); 569 570 final Transformer transformer = this.newTransformer(); 571 final DOMSource source = new DOMSource(document); 572 try { 573 transformer.transform(source, new StreamResult(writer)); 574 } catch (final TransformerException e) { 575 throw new ConfigurateException(node, e); 576 } 577 } 578 579 private void appendCommentIfNecessary(final Element parent, final ConfigurationNode node) { 580 final @Nullable Node possibleComment = this.createCommentNode(parent.getOwnerDocument(), node); 581 if (possibleComment != null) { 582 parent.appendChild(possibleComment); 583 } 584 } 585 586 private @Nullable Node createCommentNode(final Document doc, final ConfigurationNode node) { 587 if (node instanceof CommentedConfigurationNodeIntermediary<?>) { 588 final @Nullable String comment = ((CommentedConfigurationNodeIntermediary<?>) node).comment(); 589 if (comment != null) { 590 return doc.createComment(" " + comment.trim() + " "); 591 } 592 } 593 return null; 594 } 595 596 private Element writeNode(final Document document, final ConfigurationNode node, final @Nullable String forcedTag) { 597 String tag = this.defaultTagName; 598 Map<String, String> attributes = Collections.emptyMap(); 599 600 if (node instanceof AttributedConfigurationNode) { 601 final AttributedConfigurationNode attributedNode = (AttributedConfigurationNode) node; 602 tag = attributedNode.tagName(); 603 attributes = attributedNode.attributes(); 604 } 605 606 final Element element = document.createElement(forcedTag == null ? tag : forcedTag); 607 for (final Map.Entry<String, String> attribute : attributes.entrySet()) { 608 element.setAttribute(attribute.getKey(), attribute.getValue()); 609 } 610 611 if (node.isMap()) { 612 for (final Map.Entry<Object, ? extends ConfigurationNode> child : node.childrenMap().entrySet()) { 613 this.appendCommentIfNecessary(element, child.getValue()); 614 element.appendChild(this.writeNode(document, child.getValue(), child.getKey().toString())); 615 } 616 } else if (node.isList()) { 617 if (this.writeExplicitType) { 618 element.setAttribute(ATTRIBUTE_TYPE, "list"); 619 } 620 for (final ConfigurationNode child : node.childrenList()) { 621 this.appendCommentIfNecessary(element, child); 622 element.appendChild(this.writeNode(document, child, null)); 623 } 624 } else { 625 element.appendChild(document.createTextNode(Objects.toString(node.rawScalar()))); 626 } 627 628 return element; 629 } 630 631 @Override 632 public AttributedConfigurationNode createNode(ConfigurationOptions options) { 633 options = options.nativeTypes(NATIVE_TYPES); 634 return AttributedConfigurationNode.root("root", options); 635 } 636 637 private static Object parseValue(final String value) { 638 if (value.equals("true") || value.equals("false")) { 639 return Boolean.parseBoolean(value); 640 } 641 642 try { 643 final double doubleValue = Double.parseDouble(value); 644 if (isInteger(doubleValue)) { 645 final long longValue = Long.parseLong(value); // prevent losing precision 646 final int intValue = (int) longValue; 647 if (longValue == intValue) { 648 return intValue; 649 } else { 650 return longValue; 651 } 652 } 653 return doubleValue; 654 } catch (final NumberFormatException e) { 655 return value; 656 } 657 } 658 659 private static boolean isInteger(final double value) { 660 return !Double.isNaN(value) && Double.isFinite(value) && value == Math.rint(value); 661 } 662 663}