From 88fb14a5fcc11fd816718ef854b4e93666da576b Mon Sep 17 00:00:00 2001 From: Sahana Bogar Date: Sun, 16 Aug 2026 19:03:02 +0530 Subject: [PATCH 1/5] fix leading digit in AlwaysOnBase64NameProcessor encoded names --- .../dataformat/xml/XmlNameProcessors.java | 26 +++++++++++++++++-- .../xml/misc/XmlNameEscapeTest.java | 26 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java b/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java index 43cd46f7..e23755ed 100644 --- a/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java +++ b/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java @@ -229,16 +229,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..a530222d 100644 --- a/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java +++ b/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java @@ -10,6 +10,7 @@ import tools.jackson.dataformat.xml.*; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -92,6 +93,31 @@ public void testAlwaysOnBase64() throws Exception { assertEquals(dto, reversed); } + // base64url's alphabet includes digits, but a digit can not start an XML name. + // Names whose first character is U+0400 or above encode to a leading digit, so + // the "always on" processor has to keep the encoded name a valid NameStartChar + // and still round-trip. + @Test + public void testAlwaysOnBase64NonAsciiKeysRoundTrip() throws Exception { + DTO dto = new DTO(); + // U+4E2D U+6587 (Chinese) encodes to a name starting with a digit + dto.badMap.put(new String(new int[] { 0x4E2D, 0x6587 }, 0, 2), "cjk"); + // U+043F U+0440 U+0438 U+0432 (Cyrillic) + dto.badMap.put(new String(new int[] { 0x43F, 0x440, 0x438, 0x432 }, 0, 4), "cyrillic"); + dto.badMap.put("abc", "ascii"); // starts with a letter, unchanged + + XmlMapper mapper = XmlMapper.builder( + xmlFactory(XmlNameProcessors.newAlwaysOnBase64Processor()) + ).build(); + + final String res = mapper.writeValueAsString(dto); + // no encoded element/attribute name may start with a digit + assertFalse(res.matches("(?s).*<[0-9].*"), res); + + DTO reversed = mapper.readValue(res, DTO.class); + assertEquals(dto, reversed); + } + @Test public void testReplace() throws Exception { DTO dto = new DTO(); From 22ec299a3dd22aa19b914a1dfa3d360b99aa467a Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 21 Aug 2026 18:33:08 -0700 Subject: [PATCH 2/5] Add release notes --- release-notes/CREDITS | 3 +++ release-notes/VERSION | 2 ++ 2 files changed, 5 insertions(+) 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) From 08fc7f433838504fa4e833bc35b5907d7bf50a9e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 21 Aug 2026 18:43:31 -0700 Subject: [PATCH 3/5] Document `_` name-start marker in `newAlwaysOnBase64Processor()` javadoc Javadoc still claimed no prefix is ever added, which no longer holds for names encoding to a leading digit (U+0400 and above). Co-Authored-By: Claude Opus 5 (1M context) --- .../dataformat/xml/XmlNameProcessors.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java b/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java index e23755ed..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(); From 032b91eb359152a4614373e10034c1a7287b47be Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 21 Aug 2026 18:53:44 -0700 Subject: [PATCH 4/5] Pin encoded names, U+0400 boundary and attribute case in always-on base64 tests Existing test only checked that no element name starts with a digit, which would still pass if the marker scheme changed. Assert the exact encoded names, add the U+03FF/U+0400 boundary where the marker starts being needed, and cover attribute names (same NameStartChar rule, same call site). Co-Authored-By: Claude Opus 5 (1M context) --- .../xml/misc/XmlNameEscapeTest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 a530222d..8d28e491 100644 --- a/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java +++ b/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java @@ -8,6 +8,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.assertFalse; @@ -113,11 +114,63 @@ public void testAlwaysOnBase64NonAsciiKeysRoundTrip() throws Exception { final String res = mapper.writeValueAsString(dto); // no encoded element/attribute name may start with a digit assertFalse(res.matches("(?s).*<[0-9].*"), res); + // digit-leading encodings carry the `_` marker... + assertTrue(res.contains("<_5Lit5paH>cjk"), res); + assertTrue(res.contains("<_0L_RgNC40LI>cyrillic"), res); + // ... and letter-leading ones are written exactly as before + assertTrue(res.contains("ascii"), res); DTO reversed = mapper.readValue(res, DTO.class); assertEquals(dto, reversed); } + // U+0400 is the first code point whose base64url encoding begins with a digit + // (lead byte 0xD0 -> index 52 -> '0'), so it is the exact boundary at which the + // `_` marker starts being needed; U+03FF just below it still encodes to a letter. + @Test + public void testAlwaysOnBase64NameStartBoundary() throws Exception { + DTO dto = new DTO(); + dto.badMap.put(new String(new int[] { 0x3FF }, 0, 1), "below"); + dto.badMap.put(new String(new int[] { 0x400 }, 0, 1), "at"); + + XmlMapper mapper = XmlMapper.builder( + xmlFactory(XmlNameProcessors.newAlwaysOnBase64Processor()) + ).build(); + + final String res = mapper.writeValueAsString(dto); + // U+03FF -> "z78", already a valid name start: left alone + assertTrue(res.contains("below"), res); + // U+0400 -> "0IA", needs the marker + assertTrue(res.contains("<_0IA>at"), res); + + DTO reversed = mapper.readValue(res, DTO.class); + assertEquals(dto, reversed); + } + + public static class AttrDTO { + // U+0400 U+0066 U+0069 U+0072 U+0073 U+0074 ("first" prefixed with Cyrillic IE) + @JacksonXmlProperty(localName = "\u0400first", isAttribute = true) + public String attr; + + protected AttrDTO() { } + public AttrDTO(String a) { attr = a; } + } + + // Attribute names have the same NameStartChar rule as element names and go + // through the same processor, so the marker has to apply there too. + @Test + public void testAlwaysOnBase64AttributeNameRoundTrip() throws Exception { + XmlMapper mapper = XmlMapper.builder( + xmlFactory(XmlNameProcessors.newAlwaysOnBase64Processor()) + ).build(); + + final String res = mapper.writeValueAsString(new AttrDTO("x")); + assertTrue(res.contains("_0IBmaXJzdA=\"x\""), res); + + AttrDTO reversed = mapper.readValue(res, AttrDTO.class); + assertEquals("x", reversed.attr); + } + @Test public void testReplace() throws Exception { DTO dto = new DTO(); From f506e647f7dc6b6e55ac4d042474c6a714bdc1a4 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 21 Aug 2026 19:22:42 -0700 Subject: [PATCH 5/5] Test improvements --- .../xml/misc/XmlNameEscapeTest.java | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) 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 8d28e491..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; @@ -11,7 +12,6 @@ import tools.jackson.dataformat.xml.annotation.JacksonXmlProperty; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -20,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 badMap = new HashMap<>(); @@ -112,8 +116,6 @@ public void testAlwaysOnBase64NonAsciiKeysRoundTrip() throws Exception { ).build(); final String res = mapper.writeValueAsString(dto); - // no encoded element/attribute name may start with a digit - assertFalse(res.matches("(?s).*<[0-9].*"), res); // digit-leading encodings carry the `_` marker... assertTrue(res.contains("<_5Lit5paH>cjk"), res); assertTrue(res.contains("<_0L_RgNC40LI>cyrillic"), res); @@ -171,6 +173,44 @@ public void testAlwaysOnBase64AttributeNameRoundTrip() throws Exception { assertEquals("x", reversed.attr); } + // Whatever the name, the "always on" processor has to hold two invariants: + // what it emits is a valid XML name, and decoding gives back exactly what was + // encoded. Checked directly on the processor since some of these names (the + // empty one in particular) can not be produced through a Map key. + @Test + public void testAlwaysOnBase64NameInvariants() throws Exception { + final String[] names = new String[] { + "", // degenerate, but must not fail + "abc", // encodes to a letter: no marker needed + "123", + "$ I am ! &;", + new String(new int[] { 0x3FF }, 0, 1), // last code point encoding to a letter + new String(new int[] { 0x400 }, 0, 1), // first code point encoding to a digit + new String(new int[] { 0x43F, 0x440, 0x438, 0x432 }, 0, 4), // Cyrillic + new String(new int[] { 0x4E2D, 0x6587 }, 0, 2), // CJK + new String(new int[] { 0x1F600 }, 0, 1), // emoji, 4-byte UTF-8 + }; + final XmlNameProcessor proc = XmlNameProcessors.newAlwaysOnBase64Processor(); + + for (String name : names) { + XmlNameProcessor.XmlName xmlName = new XmlNameProcessor.XmlName(); + xmlName.localPart = name; + + proc.encodeName(xmlName); + final String encoded = xmlName.localPart; + if (name.isEmpty()) { + assertEquals("", encoded, "Empty name should encode to empty name"); + } else { + assertTrue(VALID_XML_NAME.matcher(encoded).matches(), + "Invalid XML name '"+encoded+"' encoded from '"+name+"'"); + } + + proc.decodeName(xmlName); + assertEquals(name, xmlName.localPart, + "Failed round-trip of '"+name+"' (encoded as '"+encoded+"')"); + } + } + @Test public void testReplace() throws Exception { DTO dto = new DTO();