Repository navigation
Xml: reject attributes that only exist because of ANTLR error recovery - #8562
Conversation
| /// Reject an attribute ANTLR only produced by recovering from malformed markup. Such a tree can still | ||
| /// reprint byte-for-byte — the dropped source survives in a prefix — so `requirePrintEqualsInput` misses it. | ||
| private void requireWellFormedAttribute(boolean wellFormed, ParserRuleContext ctx) { | ||
| if (!wellFormed) { |
There was a problem hiding this comment.
We are only checking if StringUtils.isBlank isn't there a more specific checks of well formed?
There was a problem hiding this comment.
Good push — there is a more specific check available, and I've switched to it in 40e97b3.
To explain why it started as a blankness check: the four gaps being guarded are exactly XML 1.0's S production, so "holds only whitespace" is the well-formedness condition there rather than a proxy for it.
STag ::= '<' Name (S Attribute)* S? '>' -- attribute prefix, beforeTagDelimiterPrefix
Attribute ::= Name Eq AttValue
Eq ::= S? '=' S? -- beforeEquals, value prefix
S ::= (#x20 | #x9 | #xD | #xA)+
It is also the only check that can see lexer-level dropped characters. Every whitespace rule in the grammar is -> skip, and an unmatched character raises a lexer error and is consumed without ever producing a token — so nothing token-based (adjacency, child count, ErrorNode) can observe it. That is precisely the <t k="" x = 1 "v"/> case: 1 is not a NameStartChar, never enters the token stream, and silently relocates into a prefix. The isSourceToken half catches tokens recovery inserted; the whitespace half catches text it dropped. Neither alone is sufficient — disabling either lets cases through.
But you're right that the character set was wrong. StringUtils.isBlank also accepts \f, vertical tab, and non-ASCII Unicode spaces, while inside a tag the lexer skips only:
mode INSIDE;
S : [ \t\r\n] -> skip ; // XMLLexer.g4:130So a gap holding \f was never whitespace the lexer consumed — it is a character it failed to match and dropped, i.e. exactly what this guard exists to catch, waved through. Now using StringUtils.indexOfNonWhitespace, whose character set already matches that rule and the spec.
Differential before/after, all previously accepted:
<t k=""\fabc="v"/> ACCEPTED -> REJECTED
<t k="" \fabc="v"/> ACCEPTED -> REJECTED
<t\fk="v"/> ACCEPTED -> REJECTED
<t k="v"\f/> ACCEPTED -> REJECTED
<t k="v"\v/> ACCEPTED -> REJECTED
<t k="v" /> ACCEPTED -> REJECTED
<t k=""abc="v"/> ACCEPTED -> ACCEPTED (deliberate, see below)
Two things I considered and deliberately left out:
Requiring a non-empty separator. XML's (S Attribute)* makes the S mandatory, so <a b="1"c="2"/> is malformed and still parses today. I left it: the LST is faithful and reprints byte-for-byte, so there is no corruption to prevent, and it is unrelated to this bug.
Failing on any ANTLR syntax error. More direct, but no parser in this repo does it — all six ANTLR modules forward to ctx.getOnError() and record nothing, so parseInputs cannot learn an error occurred. It would need a flag shared across the two listener instances, and it is far broader than these five positions: it would also reject the content-level and closing-tag recoveries XmlParserVisitor deliberately tolerates.
Blast radius unchanged: 12,409 XML and POM files from a local ~/.m2/repository and Gradle module cache still parse with zero errors under the stricter set.
`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.
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.
The guards accepted any StringUtils.isBlank separator, which includes \f, vertical tab and non-ASCII Unicode spaces. Inside a tag the lexer skips only [ \t\r\n], so those characters are not whitespace the lexer consumed; they are characters it failed to match and dropped, which is exactly what these guards exist to catch. Reuse StringUtils.indexOfNonWhitespace, whose character set already matches the grammar's INSIDE-mode S rule and XML 1.0 section 2.3.
c363eab to
5055f0d
Compare
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:
The separator check matches XML 1.0's
Sproduction (#x20 | #x9 | #xD | #xA) exactly, viaStringUtils.indexOfNonWhitespace, rather thanStringUtils.isBlank— which also accepts\f, vertical taband non-ASCII Unicode spaces. Inside a tag the lexer skips only
[ \t\r\n](XMLLexer.g4,mode INSIDE),so a separator holding any other "whitespace" is not whitespace the lexer consumed; it is a character it
failed to match and dropped. 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.On the separator character set specifically:
isBlankandindexOfNonWhitespacediffer only onall-whitespace strings containing at least one of 21 characters (
\f, vertical tab, and 19 non-ASCIIwhitespace characters). No file under
rewrite-xml/src/testorrewrite-maven/src/testcontains any ofthem, raw or as a Java escape, apart from the case added here — so no existing test input can take a
different path.
Two checks on the blast radius of the added strictness:
~/.m2/repository(1,720) and Gradle module cache (10,689) with 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.