TestCharRefAsEntsSplit292.java
package wstxtest.stream;
import java.io.StringWriter;
import javax.xml.stream.XMLInputFactory;
import org.junit.jupiter.api.Test;
import org.codehaus.stax2.XMLStreamReader2;
import com.ctc.wstx.api.WstxInputProperties;
/**
* Test for <a href="https://github.com/FasterXML/woodstox/issues/292">#292</a>:
* with {@code doTreatCharRefsAsEnts} enabled, character references (and pre-defined
* entities) that straddle the internal input buffer boundary used to be silently
* dropped, corrupting both text content and attribute values.
*/
public class TestCharRefAsEntsSplit292 extends BaseStreamTest
{
// Enough references (against a tiny input buffer) to guarantee that some end
// up split across buffer boundaries:
private final static int ENTITY_COUNT = 1000;
// Reference forms to exercise: pre-defined entity, decimal char ref, hex char
// ref, and a supplementary (surrogate-pair) char ref -- the last one takes a
// distinct 2-char replacement path.
private final static String[] REFS = {
""", """, """, "😀"
};
private final static String[] REPLACEMENTS = {
"\"", "\"", "\"", "����" // U+1F600
};
// getText() path, coalescing (single CHARACTERS event)
@Test
public void testNoCorruptionCoalescingGetText() throws Exception {
for (int r = 0; r < REFS.length; ++r) {
XMLStreamReader2 sr = (XMLStreamReader2) constructStreamReader(factory(true), buildText(REFS[r]));
assertTokenType(START_ELEMENT, sr.next());
assertTokenType(CHARACTERS, sr.next());
assertEquals("ref="+REFS[r], expected(REPLACEMENTS[r]), sr.getText());
assertTokenType(END_ELEMENT, sr.next());
sr.close();
}
}
// getText() path, non-coalescing (multiple CHARACTERS segments).
// Also asserts the token contract: with the default (high) min-text-segment,
// mid-content char refs are inlined into CHARACTERS rather than systematically
// promoted to standalone ENTITY_REFERENCE events -- so a regression that flips
// inlining back into separate entity events is caught, not masked by
// concatenating both event types.
//
// Note: this is a "vast majority are inlined" assertion, not "exactly zero".
// A non-coalescing segment boundary can legitimately fall on a '&' (it is
// driven by output-buffer sizing, not content), making an occasional ref a
// standalone event with no data loss; only a wholesale flip (every ref) is a
// real regression, and that would push entityRefs to ~ENTITY_COUNT.
@Test
public void testNoCorruptionNonCoalescingGetText() throws Exception {
for (int r = 0; r < REFS.length; ++r) {
XMLStreamReader2 sr = (XMLStreamReader2) constructStreamReader(factory(false), buildText(REFS[r]));
assertTokenType(START_ELEMENT, sr.next());
StringBuilder actual = new StringBuilder();
int chars = 0, entityRefs = 0;
int t;
while ((t = sr.next()) != END_ELEMENT) {
if (t == CHARACTERS) {
++chars;
actual.append(sr.getText());
} else if (t == ENTITY_REFERENCE) {
++entityRefs;
actual.append(sr.getText());
}
}
assertEquals("ref="+REFS[r], expected(REPLACEMENTS[r]), actual.toString());
if (chars < 1) {
fail("ref="+REFS[r]+": expected at least one CHARACTERS event");
}
// Tolerate incidental boundary-aligned events; catch a wholesale flip:
if (entityRefs >= ENTITY_COUNT / 10) {
fail("ref="+REFS[r]+": expected char refs to be inlined as CHARACTERS, but got "
+entityRefs+" ENTITY_REFERENCE event(s) out of "+ENTITY_COUNT+" refs");
}
sr.close();
}
}
// getText(Writer) path -> readAndWriteText / readAndWriteCoalesced
@Test
public void testNoCorruptionGetTextWriter() throws Exception {
for (int r = 0; r < REFS.length; ++r) {
XMLStreamReader2 sr = (XMLStreamReader2) constructStreamReader(factory(true), buildText(REFS[r]));
assertTokenType(START_ELEMENT, sr.next());
assertTokenType(CHARACTERS, sr.next());
StringWriter w = new StringWriter();
sr.getText(w, false);
assertEquals("ref="+REFS[r], expected(REPLACEMENTS[r]), w.toString());
assertTokenType(END_ELEMENT, sr.next());
sr.close();
}
}
// parseAttrValue() path
@Test
public void testNoCorruptionAttributeValue() throws Exception {
for (int r = 0; r < REFS.length; ++r) {
XMLStreamReader2 sr = (XMLStreamReader2) constructStreamReader(factory(false), buildAttr(REFS[r]));
assertTokenType(START_ELEMENT, sr.next());
assertEquals(1, sr.getAttributeCount());
assertEquals("ref="+REFS[r], expected(REPLACEMENTS[r]), sr.getAttributeValue(0));
assertTokenType(END_ELEMENT, sr.next());
sr.close();
}
}
// Deterministic coverage: rather than relying on volume to probabilistically
// straddle a boundary, sweep the leading-pad length so the reference's start
// offset covers every alignment relative to the (small) input buffer. This
// guarantees the split path is exercised regardless of future buffer-refill
// alignment changes -- in both element content and attribute values.
@Test
public void testSplitBoundarySweep() throws Exception {
final int bufLen = 11;
for (int pad = 0; pad <= 2 * bufLen + 8; ++pad) {
StringBuilder p = new StringBuilder();
for (int i = 0; i < pad; ++i) {
p.append('x');
}
for (int r = 0; r < REFS.length; ++r) {
String body = p + REFS[r];
String exp = p + REPLACEMENTS[r];
String desc = "pad="+pad+",ref="+REFS[r];
// element content: accumulate across CHARACTERS/ENTITY_REFERENCE,
// since a ref at the very start of content is a standalone event
XMLStreamReader2 sr = (XMLStreamReader2) constructStreamReader(
factory(false, false, bufLen), "<root>"+body+"</root>");
assertTokenType(START_ELEMENT, sr.next());
StringBuilder actual = new StringBuilder();
int t;
while ((t = sr.next()) != END_ELEMENT) {
if (t == CHARACTERS || t == ENTITY_REFERENCE) {
actual.append(sr.getText());
}
}
assertEquals(desc, exp, actual.toString());
sr.close();
// attribute value
sr = (XMLStreamReader2) constructStreamReader(
factory(false, false, bufLen), "<root a=\""+body+"\"/>");
assertTokenType(START_ELEMENT, sr.next());
assertEquals(desc, exp, sr.getAttributeValue(0));
sr.close();
}
}
}
// Deterministic coverage of the getText(Writer) sink (readAndWriteText /
// readAndWriteCoalesced), for BOTH coalescing and non-coalescing readers --
// the cursor sweep above never exercises the Writer path. A leading text char
// keeps the body a single CHARACTERS event (content stays < min-text-segment),
// and the pad sweep makes the trailing ref straddle the buffer at every
// alignment.
@Test
public void testGetTextWriterSplitSweep() throws Exception {
final int bufLen = 11;
final boolean[] coalescingModes = { false, true };
for (boolean coalescing : coalescingModes) {
for (int pad = 0; pad <= 2 * bufLen + 8; ++pad) {
StringBuilder p = new StringBuilder("z"); // leading text -> CHARACTERS, not ENTITY_REFERENCE
for (int i = 0; i < pad; ++i) {
p.append('x');
}
for (int r = 0; r < REFS.length; ++r) {
String exp = p + REPLACEMENTS[r];
String desc = "coalescing="+coalescing+",pad="+pad+",ref="+REFS[r];
XMLStreamReader2 sr = (XMLStreamReader2) constructStreamReader(
factory(coalescing, false, bufLen), "<root>"+p+REFS[r]+"</root>");
assertTokenType(START_ELEMENT, sr.next());
assertTokenType(CHARACTERS, sr.next());
StringWriter w = new StringWriter();
sr.getText(w, false);
assertEquals(desc, exp, w.toString());
sr.close();
}
}
}
}
// Nested input source interop: a general-entity reference (`≥`) creates a
// new input source whose expansion itself contains char refs. This exercises
// the `mInput` discriminator in takeInlineCharRefReplacement(): the general
// entity pushes a source (mInput changes -> must NOT be inlined as a char ref),
// while the `"` refs within the expansion are inlined while the active
// source is the entity's, not the root document's. Covers content + attribute.
@Test
public void testNestedEntityCharRefs() throws Exception {
// Expansion with several char refs embedded among longer runs of text:
final String geRaw = "aaaaaaaaaaaaaaaaaaaa"bbbb"cccccccccccccccccccc"dddd";
final String geExpanded = "aaaaaaaaaaaaaaaaaaaa\"bbbb\"cccccccccccccccccccc\"dddd";
final String dtd = "<!DOCTYPE root [<!ENTITY ge \"" + geRaw + "\">]>";
final String xml = dtd + "<root a=\"X≥Y\">P≥Q</root>";
for (int bufLen : new int[] { 16, 24, 32, 64 }) {
XMLStreamReader2 sr = (XMLStreamReader2) constructStreamReader(
factory(true, true, bufLen), xml);
int t;
while ((t = sr.next()) != START_ELEMENT) {
; // skip DTD (and any prolog) event(s)
}
String desc = "bufLen=" + bufLen;
assertEquals(desc, "X" + geExpanded + "Y", sr.getAttributeValue(0));
StringBuilder content = new StringBuilder();
while ((t = sr.next()) != END_ELEMENT) {
if (t == CHARACTERS || t == ENTITY_REFERENCE) {
content.append(sr.getText());
}
}
assertEquals(desc, "P" + geExpanded + "Q", content.toString());
sr.close();
}
}
/*
///////////////////////////////////////////////////////////////////////
// Helper methods
///////////////////////////////////////////////////////////////////////
*/
private static String buildText(String ref) {
StringBuilder sb = new StringBuilder("<root>");
for (int i = 0; i < ENTITY_COUNT; i++) {
sb.append(i).append(ref);
}
return sb.append("</root>").toString();
}
private static String buildAttr(String ref) {
StringBuilder sb = new StringBuilder("<root a=\"");
for (int i = 0; i < ENTITY_COUNT; i++) {
sb.append(i).append(ref);
}
return sb.append("\"/>").toString();
}
private static String expected(String replacement) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ENTITY_COUNT; i++) {
sb.append(i).append(replacement);
}
return sb.toString();
}
private XMLInputFactory factory(boolean coalescing) throws Exception {
// Tiny buffer to force splitting of references across reads:
return factory(coalescing, false, 32);
}
private XMLInputFactory factory(boolean coalescing, boolean supportDTD, int bufLen) throws Exception {
XMLInputFactory f = getInputFactory();
setCoalescing(f, coalescing);
setReplaceEntities(f, true);
setValidating(f, false);
setSupportDTD(f, supportDTD);
f.setProperty(WstxInputProperties.P_TREAT_CHAR_REFS_AS_ENTS, Boolean.TRUE);
f.setProperty(WstxInputProperties.P_INPUT_BUFFER_LENGTH, Integer.valueOf(bufLen));
return f;
}
}