From 32e9512736eaa95222c22321066115b810b86647 Mon Sep 17 00:00:00 2001 From: Sahana Bogar Date: Thu, 1 Oct 2026 12:41:56 +0530 Subject: [PATCH] escape colon in names written by escaping XmlNameProcessors --- release-notes/CREDITS | 4 ++ release-notes/VERSION | 3 ++ .../dataformat/xml/XmlNameProcessors.java | 24 +++++++---- .../xml/misc/XmlNameEscapeTest.java | 42 +++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/release-notes/CREDITS b/release-notes/CREDITS index c0f60c0a..b336ffaa 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -221,3 +221,7 @@ Christian Beikov (@beikov) * Fixed #915: Retain constraints and stream features in `XmlFactory.readResolve()` (3.1.7) + * Fixed #921: Escape colon in XML names written by `XmlNameProcessors` escaping + processors (`` written for name "a:b" has an undeclared prefix, can not + be read back) + (3.3.0) diff --git a/release-notes/VERSION b/release-notes/VERSION index cb38a76c..c1c0426e 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -50,6 +50,9 @@ Version: 3.x (for earlier see VERSION-2.x) #913: `XmlMapper.Builder.defaultUseWrapper()` changes mapper that builder was created from (via `rebuild()`), or has already built (fix by @Sahana2524) +#921: Escape colon in XML names written by `XmlNameProcessors` escaping processors + (`` written for name "a:b" has an undeclared prefix, can not be read back) + (fix by @Sahana2524) 3.2.3 (21-Sep-2026) diff --git a/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java b/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java index e3e83557..ca22b81a 100644 --- a/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java +++ b/src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java @@ -51,8 +51,6 @@ public static XmlNameProcessor newPassthroughProcessor() { * *
  • Hyphen ({@code -}) in position OTHER than the first character *
  • - *
  • Colon (only exposed if underlying parser is in non-namespace-aware mode) - *
  • * * in an * XML name with a replacement string. This is a one-way processor, since @@ -63,6 +61,10 @@ public static XmlNameProcessor newPassthroughProcessor() { *

    * NOTE: this processor works for US-ASCII based element and attribute names * but is unlikely to work well for many "international" use cases. + *

    + * NOTE: a colon is replaced too. Names are written namespace-aware, so a colon + * in one is read back as a prefix that nothing declares, leaving output this + * module can not read. * *

    {@code
          * 
    @@ -99,8 +101,6 @@ public static XmlNameProcessor newReplacementProcessor() {
          *   
          * 
  • Hyphen ({@code -}) in position OTHER than the first character *
  • - *
  • Colon (only exposed if underlying parser is in non-namespace-aware mode) - *
  • * * with a base64-encoded version. Here the * base64url @@ -122,6 +122,11 @@ public static XmlNameProcessor newReplacementProcessor() { * NOTE: names that already start with {@code prefix} are escaped as well, even * though they are otherwise valid, so that decoding cannot confuse them with * names this processor encoded. + *

    + * NOTE: a name containing a colon is escaped too. Names are written + * namespace-aware, so a colon in one is read back as a prefix that nothing + * declares, leaving output this module can not read; escaping keeps the colon + * in the decoded name instead. * * @param prefix The prefix to use for name that are escaped */ @@ -182,8 +187,10 @@ public void decodeName(XmlName name) { } static class ReplaceNameProcessor implements XmlNameProcessor { private static final long serialVersionUID = 1L; - private static final Pattern BEGIN_MATCHER = Pattern.compile("^[^a-zA-Z_:]"); - private static final Pattern MAIN_MATCHER = Pattern.compile("[^a-zA-Z0-9_:-]"); + // A colon counts as invalid: the writer is namespace-aware and declares no + // prefix for one, so a name carrying it comes out non-well-formed + private static final Pattern BEGIN_MATCHER = Pattern.compile("^[^a-zA-Z_]"); + private static final Pattern MAIN_MATCHER = Pattern.compile("[^a-zA-Z0-9_-]"); private final String _replacement; @@ -211,7 +218,10 @@ static class Base64NameProcessor implements XmlNameProcessor { private static final Base64.Decoder BASE64_DECODER = Base64.getUrlDecoder(); private static final Base64.Encoder BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding(); - private static final Pattern VALID_XML_NAME = Pattern.compile("[a-zA-Z_:]([a-zA-Z0-9_:.-])*"); + // Colon excluded on purpose: see newBase64Processor(String). Only affects + // encoding; decodeName() keys off the prefix, so names read from a + // non-namespace-aware parser are still passed through untouched. + private static final Pattern VALID_XML_NAME = Pattern.compile("[a-zA-Z_]([a-zA-Z0-9_.-])*"); private final String _prefix; 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 39e1c07c..aa1719d0 100644 --- a/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java +++ b/src/test/java/tools/jackson/dataformat/xml/misc/XmlNameEscapeTest.java @@ -228,6 +228,48 @@ public void testReplace() throws Exception { assertNotNull(reversed); } + // A colon in a content-derived name (here a Map key) is read back as a + // namespace prefix, and nothing declares one: both escaping processors used to + // treat it as already-valid and write it through, so the document they produced + // could not be read again. Names with more than one colon do not even parse. + @Test + public void testColonInNameRoundTrips() throws Exception { + final String[] keys = new String[] { + "a:b", ":leading", "a:b:c", "xmlns:x", "xsi:nil" + }; + final XmlMapper[] mappers = new XmlMapper[] { + XmlMapper.builder(xmlFactory(XmlNameProcessors.newBase64Processor())).build(), + XmlMapper.builder(xmlFactory(XmlNameProcessors.newAlwaysOnBase64Processor())).build(), + }; + + for (XmlMapper mapper : mappers) { + for (String key : keys) { + DTO dto = new DTO(); + dto.badMap.put(key, "xyz"); + + final String res = mapper.writeValueAsString(dto); + assertEquals(dto, mapper.readValue(res, DTO.class), + "Failed round-trip of key '"+key+"', written as: "+res); + } + } + } + + // Replacement is one-way so the key does not survive, but what is written still + // has to be readable (and free of the colon that made it unreadable). + @Test + public void testColonInNameReplaced() throws Exception { + DTO dto = new DTO(); + dto.badMap.put("a:b", "xyz"); + + XmlMapper mapper = XmlMapper.builder( + xmlFactory(XmlNameProcessors.newReplacementProcessor()) + ).build(); + + final String res = mapper.writeValueAsString(dto); + assertTrue(res.contains("xyz"), res); + assertNotNull(mapper.readValue(res, DTO.class)); + } + public static class BadVarNameDTO { public int $someVar$ = 5; }