diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 9468658b..3ab3ce0f 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -183,6 +183,9 @@ Christian Beikov (@beikov) * Fixed #893: Decode element name before matching virtual wrapper in `_initStartElement` (3.3.0) + * Fixed #895: Avoid leading digit in `AlwaysOnBase64NameProcessor`-encoded + XML names + (3.3.0) * Fixed #899: Return `null` from `nextStringValue()` at end-of-input (instead of throwing `IllegalStateException`) (3.3.0) diff --git a/release-notes/VERSION b/release-notes/VERSION index bfb1aa7b..a108056f 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -28,6 +28,8 @@ Version: 3.x (for earlier see VERSION-2.x) (fix by @Sahana2524) #893: Decode element name before matching virtual wrapper in `_initStartElement` (fix by @Sahana2524) +#895: Avoid leading digit in `AlwaysOnBase64NameProcessor`-encoded XML names + (fix by @Sahana2524) #899: Return `null` from `nextStringValue()` at end-of-input (instead of throwing `IllegalStateException`) (fix by @Sahana2524) diff --git a/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java b/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java index 43cd46f7..e3e83557 100644 --- a/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java +++ b/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java @@ -142,6 +142,26 @@ public static XmlNameProcessor newBase64Processor() { * always be escaped with base64. No magic prefix is required * for this case, since adding one would be redundant because all names * will be base64 encoded. + *
+ * With this processor set, a map with the key {@code "abc"} and a CJK + * key (code points U+4E2D, U+6587) will be written as: + * + *
{@code
+ *
+ *
+ * xyz
+ * <_5Lit5paH>bar
+ *
+ *
+ * }
+ *
+ * NOTE: base64url's alphabet includes digits, but a digit can not start an
+ * XML name: names starting with a character U+0400 or above encode to a
+ * leading digit. Such encodings get a single {@code _} prepended (see
+ * {@code <_5Lit5paH>} above) to restore a valid name start character, and
+ * it is stripped again when decoding. Encoding of UTF-8 bytes never itself
+ * begins with {@code _}, so the marker stays unambiguous and names that
+ * already encode to a leading letter are written unchanged.
*/
public static XmlNameProcessor newAlwaysOnBase64Processor() {
return new AlwaysOnBase64NameProcessor();
@@ -229,16 +249,38 @@ static class AlwaysOnBase64NameProcessor implements XmlNameProcessor {
private static final Base64.Decoder BASE64_DECODER = Base64.getUrlDecoder();
private static final Base64.Encoder BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding();
+ // Marker used to restore a valid XML name start character; see encodeName().
+ private static final char START_MARKER = '_';
+
public AlwaysOnBase64NameProcessor() { }
@Override
public void encodeName(XmlName name) {
- name.localPart = new String(BASE64_ENCODER.encode(name.localPart.getBytes(UTF_8)), UTF_8);
+ String encoded = new String(BASE64_ENCODER.encode(name.localPart.getBytes(UTF_8)), UTF_8);
+ // base64url's alphabet contains digits and '-', but neither can begin an
+ // XML name (only letters, '_' and ':' are NameStartChars). A name whose
+ // first character is U+0400 or above encodes to a leading digit, which
+ // produces an invalid element/attribute name and breaks the round trip
+ // this processor is meant to guarantee. A base64url encoding of UTF-8 bytes
+ // never itself begins with '_', so prepending one restores a valid start
+ // character without making decoding ambiguous.
+ if (!encoded.isEmpty() && !_isNameStartChar(encoded.charAt(0))) {
+ encoded = START_MARKER + encoded;
+ }
+ name.localPart = encoded;
}
@Override
public void decodeName(XmlName name) {
- name.localPart = new String(BASE64_DECODER.decode(name.localPart), UTF_8);
+ String localName = name.localPart;
+ if (!localName.isEmpty() && localName.charAt(0) == START_MARKER) {
+ localName = localName.substring(1);
+ }
+ name.localPart = new String(BASE64_DECODER.decode(localName), UTF_8);
+ }
+
+ private static boolean _isNameStartChar(char c) {
+ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_';
}
}
}
diff --git a/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java b/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java
index 679c5dd4..39e1c07c 100644
--- a/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java
+++ b/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java
@@ -1,6 +1,7 @@
package tools.jackson.dataformat.xml.misc;
import java.util.*;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
@@ -8,6 +9,7 @@
import tools.jackson.core.TokenStreamLocation;
import tools.jackson.core.exc.StreamReadException;
import tools.jackson.dataformat.xml.*;
+import tools.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@@ -18,6 +20,10 @@
// For [dataformat-xml#531]
public class XmlNameEscapeTest extends XmlTestUtil
{
+ // XML 1.0 `Name` production, limited to characters the base64 processors can
+ // produce: only letters, `_` and `:` may START a name, digits and `-` may not.
+ private final static Pattern VALID_XML_NAME = Pattern.compile("[a-zA-Z_:][a-zA-Z0-9_:.-]*");
+
public static class DTO {
public Map