001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.marshall.xml;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021import static org.apache.juneau.commons.utils.ThrowableUtils.*;
022
023import java.io.*;
024import java.util.*;
025
026import javax.xml.stream.*;
027
028import org.apache.juneau.commons.io.*;
029import org.apache.juneau.commons.lang.*;
030
031/**
032 * XML utility methods.
033 *
034 * <h5 class='section'>See Also:</h5><ul>
035 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/XmlSupport">XML</a>
036
037 * </ul>
038 */
039@SuppressWarnings({
040   "java:S115", // Constants use UPPER_snakeCase convention (e.g., CONST_x0000)
041   "java:S1192", // Duplicated literals read more clearly inline than as constants.
042   "java:S3776", // encodeElementNameInner(), encodeAttrName(), encodeText() and findNamespace() apply XML name/text escaping and namespace rules char-by-char in one pass
043   "resource" // Resource management handled externally
044})
045public class XmlUtils {
046
047   /**
048    * Prevents instantiation.
049    */
050   private XmlUtils() {}
051
052   // @formatter:off
053   private static final AsciiMap REPLACE_TEXT = new AsciiMap()
054      .append('&', "&amp;")
055      .append('<', "&lt;")
056      .append('>', "&gt;")
057      .append((char)0x09, "&#x0009;")
058      .append((char)0x0A, "&#x000a;")
059      .append((char)0x0D, "&#x000d;");
060
061   private static final AsciiMap REPLACE_ATTR_VAL = new AsciiMap()
062      .append('&', "&amp;")
063      .append('<', "&lt;")
064      .append('>', "&gt;")
065      .append('"', "&quot;")
066      .append('\'', "&apos;")
067      .append((char)0x09, "&#x0009;")
068      .append((char)0x0A, "&#x000a;")
069      .append((char)0x0D, "&#x000d;");
070   // @formatter:on
071
072   /**
073    * Given a list of Strings and other Objects, combines Strings that are next to each other in the list.
074    *
075    * @param value The list of text nodes to collapse.
076    *    <br>Must not be <jk>null</jk>.
077    * @return The same list.
078    */
079   public static List<Object> collapseTextNodes(List<Object> value) {
080
081      StringBuilder prev = null;
082      for (ListIterator<Object> i = value.listIterator(); i.hasNext();) {
083         Object o = i.next();
084         if (o instanceof String o2) {
085            if (prev == null)
086               prev = new StringBuilder(o2);
087            else {
088               prev.append(o2);
089               i.remove();
090               i.previous();
091               i.remove();
092               i.add(prev.toString());
093            }
094         } else {
095            prev = null;
096         }
097      }
098      return value;
099   }
100
101   /**
102    * Translates any _x####_ sequences (introduced by the various encode methods) back into their original characters.
103    *
104    * @param value The string being decoded.
105    *    <br>Can be <jk>null</jk> (returns <jk>null</jk>).
106    * @param sb The string builder to use as a scratch pad.
107    *    <br>Can be <jk>null</jk> (a new one is created).
108    * @return The decoded string, or <jk>null</jk> if the input was <jk>null</jk> or represented a <jk>null</jk> value.
109    */
110   @SuppressWarnings({
111      "java:S127" // Loop counter advances to skip _xXXXX_ escape sequences
112   })
113   public static String decode(String value, StringBuilder sb) {
114      if (value == null)
115         return null;
116      if (value.isEmpty() || value.indexOf('_') == -1)
117         return value;
118      if (sb == null)
119         sb = new StringBuilder(value.length());
120
121      for (var i = 0; i < value.length(); i++) {
122         var c = value.charAt(i);
123         if (c == '_' && isEscapeSequence(value, i)) {
124
125            var x = Integer.parseInt(value.substring(i + 2, i + 6), 16);
126
127            // If we find _x0000_, then that means a null.
128            // If we find _xE000_, then that means an empty string.
129            if (x == 0)
130               return null;
131            else if (x != 0xE000)
132               sb.append((char)x);
133
134            i += 6;
135         } else {
136            sb.append(c);
137         }
138      }
139      return sb.toString();
140   }
141
142   /**
143    * Serializes and encodes the specified object as valid XML attribute name.
144    *
145    * @param w The writer to send the output to.
146    *    <br>Must not be <jk>null</jk>.
147    * @param value The object being serialized.
148    *    <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker).
149    * @return This object.
150    * @throws IOException If a problem occurred.
151    */
152   public static Writer encodeAttrName(Writer w, Object value) throws IOException {
153      if (value == null)
154         return w.append("_x0000_");
155      var s = value.toString();
156
157      if (needsAttrNameEncoding(s)) {
158         for (var i = 0; i < s.length(); i++) {
159            var c = s.charAt(i);
160            if (i == 0) {
161               if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == ':')
162                  w.append(c);
163               else if (c == '_' && ! isEscapeSequence(s, i))
164                  w.append(c);
165               else
166                  appendPaddedHexChar(w, c);
167            } else {
168               if ((c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == ':'))
169                  w.append(c);
170               else if (c == '_' && ! isEscapeSequence(s, i))
171                  w.append(c);
172               else
173                  appendPaddedHexChar(w, c);
174            }
175         }
176      } else {
177         w.append(s);
178      }
179
180      return w;
181   }
182
183   /**
184    * Encodes the specified attribute value and sends the results to the specified writer.
185    *
186    * <p>
187    * Encodes any invalid XML text characters to <c>_x####_</c> sequences and sends the response to the specified
188    * writer.
189    * <br>Encodes <js>'&amp;'</js>, <js>'&lt;'</js>, <js>'&gt;'</js>, <js>'"'</js>, and <js>'\''</js> as XML entities.
190    * <br>Encodes invalid XML text characters to <c>_x####_</c> sequences.
191    *
192    * @param w The writer to send the output to.
193    *    <br>Must not be <jk>null</jk>.
194    * @param value The object being encoded.
195    *    <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker).
196    * @param trim
197    *    Trim the text before serializing it.
198    *    If <jk>true</jk>, leading and trailing whitespace characters will be encoded.
199    * @return The same writer passed in.
200    */
201   public static Writer encodeAttrValue(Writer w, Object value, boolean trim) {
202      try {
203         if (value == null)
204            return w.append("_x0000_");
205         var s = value.toString();
206         if (s.isEmpty())
207            return w;
208         if (trim)
209            s = s.trim();
210
211         if (needsAttrValueEncoding(s)) {
212            var len = s.length();
213            for (var i = 0; i < len; i++) {
214               var c = s.charAt(i);
215               if ((i == 0 || i == len - 1) && Character.isWhitespace(c))
216                  appendPaddedHexChar(w, c);
217               else if (REPLACE_ATTR_VAL.contains(c))
218                  w.append(REPLACE_ATTR_VAL.get(c));
219               else if (c == '_' && isEscapeSequence(s, i))
220                  appendPaddedHexChar(w, c);
221               else if (isValidXmlCharacter(c))
222                  w.append(c);
223               else
224                  appendPaddedHexChar(w, c);
225            }
226         } else {
227            w.append(s);
228         }
229      } catch (IOException e) {
230         throw toRex(e);
231      }
232
233      return w;
234   }
235
236   /**
237    * Encodes any invalid XML element name characters to <c>_x####_</c> sequences.
238    *
239    * @param value The object being encoded.
240    *    <br>Can be <jk>null</jk> (returns the <c>_x0000_</c> null marker).
241    * @return The encoded element name string.
242    */
243   public static String encodeElementName(Object value) {
244      if (value == null)
245         return "_x0000_";
246      var s = value.toString();
247      if (s.isEmpty())
248         return "_xE000_";
249
250      try {
251         if (needsElementNameEncoding(s))
252            try (var w = new StringBuilderWriter(s.length() * 2)) {
253               return encodeElementNameInner(w, s).toString();
254            }
255      } catch (IOException e) {
256         throw toRex(e); // Never happens
257      }
258
259      return s;
260   }
261
262   /**
263    * Encodes any invalid XML element name characters to <c>_x####_</c> sequences.
264    *
265    * @param w The writer to send the output to.
266    *    <br>Must not be <jk>null</jk>.
267    * @param value The object being encoded.
268    *    <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker).
269    * @return The same writer passed in.
270    */
271   public static Writer encodeElementName(Writer w, Object value) {
272      try {
273         if (value == null)
274            return w.append("_x0000_");
275         var s = value.toString();
276         if (needsElementNameEncoding(s))
277            return encodeElementNameInner(w, s);
278         w.append(s);
279      } catch (IOException e) {
280         throw toRex(e);
281      }
282      return w;
283   }
284
285   /**
286    * Encodes the specified element text and sends the results to the specified writer.
287    *
288    * <p>
289    * Encodes any invalid XML text characters to <c>_x####_</c> sequences and sends the response to the specified
290    * writer.
291    * <br>Encodes <js>'&amp;'</js>, <js>'&lt;'</js>, and <js>'&gt;'</js> as XML entities.
292    * <br>Encodes invalid XML text characters to <c>_x####_</c> sequences.
293    *
294    * @param w The writer to send the output to.
295    *    <br>Must not be <jk>null</jk>.
296    * @param value The object being encoded.
297    *    <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker).
298    * @param trim Trim the text before serializing it.
299    * @param preserveWhitespace
300    *    Specifies whether we're in preserve-whitespace mode.
301    *    (e.g. {@link XmlFormat#MIXED_PWS} or {@link XmlFormat#TEXT_PWS}.
302    *    If <jk>true</jk>, leading and trailing whitespace characters will be encoded.
303    * @return The same writer passed in.
304    */
305   public static Writer encodeText(Writer w, Object value, boolean trim, boolean preserveWhitespace) {
306
307      try {
308         if (value == null)
309            return w.append("_x0000_");
310         var s = value.toString();
311         if (s.isEmpty())
312            return w.append("_xE000_");
313         if (trim)
314            s = s.trim();
315
316         if (needsTextEncoding(s)) {
317            var len = s.length();
318            for (var i = 0; i < len; i++) {
319               var c = s.charAt(i);
320               if ((i == 0 || i == len - 1) && Character.isWhitespace(c) && ! preserveWhitespace)
321                  appendPaddedHexChar(w, c);
322               else if (REPLACE_TEXT.contains(c))
323                  w.append(REPLACE_TEXT.get(c));
324               else if (c == '_' && isEscapeSequence(s, i))
325                  appendPaddedHexChar(w, c);
326               else if (isValidXmlCharacter(c))
327                  w.append(c);
328               else
329                  appendPaddedHexChar(w, c);
330            }
331         } else {
332            w.append(s);
333         }
334      } catch (IOException e) {
335         throw toRex(e);
336      }
337
338      return w;
339   }
340
341   /**
342    * Escapes invalid XML text characters to <c>_x####_</c> sequences.
343    *
344    * @param value The object being encoded.
345    *    <br>Can be <jk>null</jk> (returns the <c>_x0000_</c> null marker).
346    * @return The encoded string.
347    */
348   public static String escapeText(Object value) {
349      if (value == null)
350         return "_x0000_";
351      var s = value.toString();
352
353      try {
354         if (! needsTextEncoding(s))
355            return s;
356         var len = s.length();
357         var sw = new StringWriter(s.length() * 2);
358         for (var i = 0; i < len; i++) {
359            var c = s.charAt(i);
360            if ((i == 0 || i == len - 1) && Character.isWhitespace(c))
361               appendPaddedHexChar(sw, c);
362            else if (c == '_' && isEscapeSequence(s, i))
363               appendPaddedHexChar(sw, c);
364            else if (isValidXmlCharacter(c))
365               sw.append(c);
366            else
367               appendPaddedHexChar(sw, c);
368         }
369         return sw.toString();
370      } catch (IOException e) {
371         throw toRex(e); // Never happens
372      }
373   }
374
375   /**
376    * Find the namespace given a list of <ja>@Xml</ja> and <ja>@XmlSchema</ja> annotations.
377    *
378    * <p>
379    * The annotations should be a parent-to-child ordering of annotations found on a class or method.
380    *
381    * @param xmls The list of <ja>@Xml</ja> annotations.
382    *    <br>Must not be <jk>null</jk>.
383    * @param schemas The list of <ja>@XmlSchema</ja> annotations.
384    *    <br>Must not be <jk>null</jk>.
385    * @return The namespace, or <jk>null</jk> if it couldn't be found.
386    */
387   public static Namespace findNamespace(List<Xml> xmls, List<XmlSchema> schemas) {
388
389      for (var i = xmls.size() - 1; i >= 0; i--) {
390         var xml = xmls.get(i);
391         var ns = findNamespace(xml.prefix(), xml.namespace(), xmls, schemas);
392         if (nn(ns))
393            return ns;
394      }
395
396      for (var i = schemas.size() - 1; i >= 0; i--) {
397         var schema = schemas.get(i);
398         var ns = findNamespace(schema.prefix(), schema.namespace(), null, schemas);
399         if (nn(ns))
400            return ns;
401      }
402
403      return null;
404   }
405
406   /**
407    * Utility method that converts the current event on the XML stream to something human-readable for debug purposes.
408    *
409    * @param r The XML stream reader whose current event is to be converted to a readable string.
410    *    <br>Must not be <jk>null</jk>.
411    * @return The event in human-readable form.
412    */
413   public static String toReadableEvent(XMLStreamReader r) {
414      int t = r.getEventType();
415      if (t == 1)
416         return "<" + r.getLocalName() + ">";
417      if (t == 2)
418         return "</" + r.getLocalName() + ">";
419      if (t == 3)
420         return "PROCESSING_INSTRUCTION";
421      if (t == 4)
422         return "CHARACTERS=[" + r.getText() + "]";
423      if (t == 5)
424         return "COMMENTS=[" + r.getText() + "]";
425      if (t == 6)
426         return "SPACE=[" + r.getText() + "]";
427      if (t == 7)
428         return "START_DOCUMENT";
429      if (t == 8)
430         return "END_DOCUMENT";
431      if (t == 9)
432         return "ENTITY_REFERENCE";
433      if (t == 10)
434         return "ATTRIBUTE";
435      if (t == 11)
436         return "DTD";
437      if (t == 12)
438         return "CDATA=[" + r.getText() + "]";
439      if (t == 13)
440         return "NAMESPACE";
441      if (t == 14)
442         return "NOTATION_DECLARATION";
443      if (t == 15)
444         return "ENTITY_DECLARATION";
445      return "UNKNOWN";
446   }
447
448   // Converts an integer to a hexadecimal string padded to 4 places.
449   private static Writer appendPaddedHexChar(Writer out, int num) throws IOException {
450      out.append("_x");
451      for (var c : toHex4(num))
452         out.append(c);
453      return out.append('_');
454   }
455
456   private static Writer encodeElementNameInner(Writer w, String s) throws IOException {
457      for (var i = 0; i < s.length(); i++) {
458         var c = s.charAt(i);
459         // @formatter:off
460         if ((c >= 'A' && c <= 'Z')
461               || (c == '_' && ! isEscapeSequence(s,i))
462               || (c >= 'a' && c <= 'z')
463               || (i != 0 && (
464                     c == '-'
465                     || c == '.'
466                     || (c >= '0' && c <= '9')
467                     || c == '\u00b7'
468                     || (c >= '\u0300' && c <= '\u036f')
469                     || (c >= '\u203f' && c <= '\u2040')
470                  ))
471               || (c >= '\u00c0' && c <= '\u00d6')
472               || (c >= '\u00d8' && c <= '\u00f6')
473               || (c >= '\u00f8' && c <= '\u02ff')
474               || (c >= '\u0370' && c <= '\u037d')
475               || (c >= '\u037f' && c <= '\u1fff')
476               || (c >= '\u200c' && c <= '\u200d')
477               || (c >= '\u2070' && c <= '\u218f')
478               || (c >= '\u2c00' && c <= '\u2fef')
479               || (c >= '\u3001' && c <= '\ud7ff')
480               || (c >= '\uf900' && c <= '\ufdcf')
481               || (c >= '\ufdf0' && c <= '\ufffd')) {
482            // @formatter:on
483            w.append(c);
484         } else {
485            appendPaddedHexChar(w, c);
486         }
487      }
488      return w;
489   }
490
491   private static Namespace findNamespace(String prefix, String ns, List<Xml> xmls, List<XmlSchema> schemas) {
492
493      // If both prefix and namespace specified, use that Namespace mapping.
494      if (! (prefix.isEmpty() || ns.isEmpty()))
495         return Namespace.of(prefix, ns);
496
497      // If only prefix specified, need to search for namespaceURI.
498      if (! prefix.isEmpty()) {
499         if (nn(xmls))
500            for (var xml2 : xmls)
501               if (xml2.prefix().equals(prefix) && ! xml2.namespace().isEmpty())
502                  return Namespace.of(prefix, xml2.namespace());
503         for (var schema : schemas) {
504            if (schema.prefix().equals(prefix) && ! schema.namespace().isEmpty())
505               return Namespace.of(prefix, schema.namespace());
506            for (var xmlNs : schema.xmlNs())
507               if (xmlNs.prefix().equals(prefix))
508                  return Namespace.of(prefix, xmlNs.namespaceURI());
509         }
510         throw brex("Found @Xml.prefix annotation with no matching URI.  prefix='%s'", prefix);
511      }
512
513      // If only namespaceURI specified, need to search for prefix. // HTT: reached only via XmlSerializer when @Xml has namespace but no prefix; not exercised by unit tests
514      if (! ns.isEmpty()) {
515         if (nn(xmls))
516            for (var xml2 : xmls)
517               if (xml2.namespace().equals(ns) && ! xml2.prefix().isEmpty())
518                  return Namespace.of(xml2.prefix(), ns);
519         for (var schema : schemas) {
520            if (schema.namespace().equals(ns) && ! schema.prefix().isEmpty())
521               return Namespace.of(schema.prefix(), ns);
522            for (var xmlNs : schema.xmlNs())
523               if (xmlNs.namespaceURI().equals(ns))
524                  return Namespace.of(xmlNs.prefix(), ns);
525         }
526      }
527
528      return null;
529   }
530
531   // Returns true if the string at the specified position is of the form "_x####_"
532   // where '#' are hexadecimal characters.
533   private static boolean isEscapeSequence(String s, int i) {
534      // @formatter:off
535      return s.length() > i+6
536         && s.charAt(i) == '_'
537         && s.charAt(i+1) == 'x'
538         && isHexCharacter(s.charAt(i+2))
539         && isHexCharacter(s.charAt(i+3))
540         && isHexCharacter(s.charAt(i+4))
541         && isHexCharacter(s.charAt(i+5))
542         && s.charAt(i+6) == '_';
543      // @formatter:on
544   }
545
546   // Returns true if the character is a hexadecimal character
547   private static boolean isHexCharacter(char c) {
548      return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F');
549   }
550
551   // Returns true if the specified character can safely be used in XML text or an attribute.
552   private static boolean isValidXmlCharacter(char c) {
553      return (c >= 0x20 && c <= 0xD7FF) /*|| c == 0xA || c == 0xD*/ || (c >= 0xE000 && c <= 0xFFFD);
554   }
555
556   private static boolean needsAttrNameEncoding(String value) {
557      // Note that this doesn't need to be perfect, just fast.
558      for (var i = 0; i < value.length(); i++) {
559         var c = value.charAt(i);
560         if (! (c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') || (i == 0 && ! (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')))
561            return true;
562      }
563      return false;
564   }
565
566   private static boolean needsAttrValueEncoding(String value) {
567      // See if we need to convert the string.
568      // Conversion is somewhat expensive, so make sure we need to do so before hand.
569      var len = value.length();
570      for (var i = 0; i < len; i++) {
571         var c = value.charAt(i);
572         if ((i == 0 || i == len - 1) && Character.isWhitespace(c))
573            return true;
574         if (REPLACE_ATTR_VAL.contains(c) || ! isValidXmlCharacter(c) || (c == '_' && isEscapeSequence(value, i)))
575            return true;
576      }
577      return false;
578   }
579
580   private static boolean needsElementNameEncoding(String value) {
581      // Note that this doesn't need to be perfect, just fast.
582      for (var i = 0; i < value.length(); i++) {
583         var c = value.charAt(i);
584         if (! (c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') || (i == 0 && (c >= '0' && c <= '9')))
585            return true;
586      }
587      return false;
588   }
589
590   private static boolean needsTextEncoding(String value) {
591      // See if we need to convert the string.
592      // Conversion is somewhat expensive, so make sure we need to do so before hand.
593      var len = value.length();
594      for (var i = 0; i < len; i++) {
595         var c = value.charAt(i);
596         if ((i == 0 || i == len - 1) && Character.isWhitespace(c))
597            return true;
598         if (REPLACE_TEXT.contains(c) || ! isValidXmlCharacter(c) || (c == '_' && isEscapeSequence(value, i)))
599            return true;
600      }
601      return false;
602   }
603}