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();