Xml: reject attributes that only exist because of ANTLR error recovery - #8562
Open
timtebeek wants to merge 5 commits into
Open
Xml: reject attributes that only exist because of ANTLR error recovery#8562timtebeek wants to merge 5 commits into
timtebeek wants to merge 5 commits into
Conversation
`attribute : Name '=' STRING` is applied to whatever tokens the lexer
produces, and on malformed markup ANTLR recovery reaches that rule with
tokens that were never written. `XmlParserVisitor.visitAttribute` built an
`Xml.Attribute` from the result regardless, which went wrong three ways:
- a missing `=` was inserted, and `XmlPrinter` re-emitted it, so
`x += "AAA,";` reprinted as `x += "AAA=,";`
- the rule's children were dropped entirely, so `c.EQUALS()` was null and
`convert` threw a `NullPointerException`
- recovery resynced past a stray token, silently truncating the attribute
value at the point the markup went wrong
The third is the worst: the dropped source survives in a neighbouring
prefix, so the document reprints byte-for-byte and `requirePrintEqualsInput`
does not catch it. The file is ingested with an attribute value that stops
at the first quote, and any recipe that edits the tag writes the truncated
value back and corrupts the file.
The usual trigger is an embedded expression containing unescaped double
quotes, e.g. `value="@{ string s = ""; s += "a,"; }"`. `STRING` is
`'"' ~[<"]* '"'`, so such a value is not representable — XML 1.0 section 2.3
does not permit a literal `"` in a `"`-delimited value — and the lexer
re-tokenises it into alternating `STRING` runs with stray tokens between.
Require that each attribute is built only from tokens that appeared in the
source, and that nothing but whitespace separates them, so these documents
fail with a message naming the real problem instead of being silently
mis-parsed. Well-formed spellings (`"`, or a single-quoted delimiter)
are unaffected and keep their full value.
The invented `=` and the `NullPointerException` have been present since the
XML module was added; the silent truncation is newer, from the `element`
rule left-factoring in #7906, which changed error recovery for the
`attribute*` loop.
timtebeek
marked this pull request as ready for review
August 19, 2026 19:52
Extend the well-formedness check to the two other rules that accept attributes, so stray tokens cannot hide in their trailing prefix, and generalize the message now that the guard also covers unquoted and value-less attributes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's wrong
XMLParser.g4appliesattribute : Name '=' STRINGto whatever the lexer produces. On malformed markup ANTLR error recovery reaches that rule with tokens that were never written, andXmlParserVisitor.visitAttributebuilt anXml.Attributefrom the result without checking. That goes wrong three different ways:<t k="" abc "v"/>=XmlPrinterre-emits it — the document reprints as<t k="" abc="v"/><t k="" a b "v"/>c.EQUALS()is null →NullPointerExceptioninconvert<t k="" x = 1 "v"/>1k=""plus an inventedx="v"The third is the one that matters. The dropped source survives in the neighbouring prefix, so the document reprints byte-for-byte and
requirePrintEqualsInputnever fires. The file is parsed, cached, and searched with an attribute value that is not what the source says.Why it shows up in practice
The usual trigger is an embedded expression containing unescaped double quotes — common in generated or hand-written config that carries a scripting snippet in an attribute:
STRINGis'"' ~[<"]* '"', so this value is not representable — XML 1.0 §2.3 does not permit a literal"inside a"-delimited value, andxmllintrejects the file too. The lexer therefore re-tokenises the value into alternatingSTRINGruns with stray tokens in the gaps, and the table above decides which symptom you get. Which one is essentially arbitrary: it depends only on the token shape of whatever sits inside the expression's string literals.On
maintoday, that document parses without error, reprints byte-for-byte, and yields:The value stops at the first quote — the addresses are not in the LST at all. A search recipe reading
valuefinds nothing and reports a clean result. Worse, a recipe that writes the attribute writes the truncated value back:ChangeTagAttribute("set-variable", "value", "REDACTED")currently emitswhich is no longer valid XML.
The fix
Require that an attribute is built only from tokens that actually appeared in the source, and that nothing but whitespace separates them:
Name,EQUALS,STRINGmust be present and not an ANTLRErrorNode(recovery marks inserted tokens as error nodes, so this catches the invented=and the null dereference)beforeEquals, and the value's prefix must be whitespace only (this catches tokens that recovery dropped, which is what silently truncates the value)beforeTagDelimiterPrefix, and the equivalents invisitXmldeclandvisitJspdirective— for the case where recovery produces no attribute at all and the stray tokens are swallowed before the closing delimiterOtherwise the parse fails with a message naming the actual problem:
Both halves are needed, and neither alone is sufficient. The whitespace checks catch tokens recovery dropped — that is what silently truncates the value. The
ErrorNodecheck catches tokens recovery inserted; there is nothing wrong with any prefix in<t k="" abc "v"/>, so a whitespace check cannot see it. Disabling either half locally lets two of the six regression cases through.XmlPrinter.visitAttributestill appends'='unconditionally. With this check in place noXml.Attributecan reach it without a real=in the source, so it is no longer reachable as a bug; making the=optional in the LST would be a much larger change for no gain today.Behaviour change
Documents matching the third row above previously parsed into an
Xml.Documentand now become aParseError. That is the point of the change — they were being parsed into a tree that did not describe the source — but it does mean files that used to ingest "successfully" will start reporting. The first two rows already failed, just less legibly (is not print idempotent, or an NPE); they now carry an actionable message.Well-formed spellings are unaffected and keep their full value, so there is a clear path for anyone hitting this:
Notes on provenance
The invented
=and theNullPointerExceptiondate to the original XML module and only became visible as parse failures oncerequirePrintEqualsInputlanded in 8.1.14 (#3459).The silent truncation is newer. Bisecting released versions (8.1.14 → 8.91.0-SNAPSHOT) puts it at 8.84.4: before that the same input failed loudly, after it the file is quietly accepted with a truncated value. The only
rewrite-xmlchange in that range is #7906, which left-factored theelementrule to support HTML void elements. The feature itself is correctly gated behindhtmlMode, but the grammar restructuring it needed is not, so it changed ANTLR's error recovery for theattribute*loop in strict XML too. Worth keeping in mind for future grammar refactors — a change that looks purely structural can move which malformed inputs get rejected.Testing
Added to
XmlParserTest:malformedAttributeIsNotSilentlyAccepted— six recovery shapes (including an orphan string in a tag and in an xmldecl) become aParseError, still print verbatim, and carry the new messageattributeValueWithUnescapedQuotes— the embedded-expression document above, asserting the messagewellFormedEmbeddedExpressionsKeepTheirFullValue—"and single-quoted delimiters keep the whole value:rewrite-xml:testand:rewrite-maven:testpass. The pre-existingmalformedBareAmpersandDoesNotThrow/malformedUnterminatedEndTagDoesNotThrow/malformedMissingRootCloseDoesNotThrowcases still pass, so lenient handling of other malformed input is unchanged.Two checks on the blast radius of the added strictness:
~/.m2/repositorywith the patched parser produces zero parse errors, so the guards do not fire on ordinary well-formed XML.<root a/>— shows every one of them was already aParseErrorbefore this change. For those the PR replaces an NPE or an idempotency failure with a readable message. The only previously-successful shapes that now fail are genuinely malformed:<t a=="1"/>, and orphan strings inside a tag.