Reset TwoByteMatcher partial match on mismatching byte

DataBufferUtils.TwoByteMatcher inherited AbstractNestedMatcher.match(byte)
without providing the mismatch fallback that its siblings implement
(KnuthMorrisPrattMatcher backtracks via its suffix-prefix table, and
SingleByteMatcher is stateless). As a result, once the first delimiter
byte had matched, the match counter stayed at 1 across any number of
intervening non-matching bytes, so a later occurrence of the second
delimiter byte falsely completed the match.

For a two-byte delimiter such as \r\n this made the matcher report a
match across non-contiguous bytes. CompositeMatcher prefers the longest
delimiter that matches at a position, so the false \r\n match was chosen
over a real single \n, causing StringDecoder to strip two bytes and drop
the character preceding a lone \n whenever a line contained a stray \r.

TwoByteMatcher now overrides match(byte) to reset the counter to 0 when
the incoming byte is not the expected next delimiter byte before
delegating to super.match(), mirroring KnuthMorrisPrattMatcher. A
genuine contiguous delimiter is unaffected.

Signed-off-by: junhyeong9812 <pickjog@gmail.com>
This commit is contained in:
junhyeong9812
2026-08-05 10:36:05 +02:00
committed by Brian Clozel
parent b556766e1a
commit 7f1966f5f5
3 changed files with 74 additions and 0 deletions
@@ -917,6 +917,14 @@ public abstract class DataBufferUtils {
super(delimiter);
Assert.isTrue(delimiter.length == 2, "Expected a 2-byte delimiter");
}
@Override
public boolean match(byte b) {
if (getMatches() > 0 && b != delimiter()[getMatches()]) {
setMatches(0);
}
return super.match(b);
}
}
@@ -153,6 +153,30 @@ class StringDecoderTests extends AbstractDecoderTests<StringDecoder> {
.verify());
}
@Test
void decodePreservesCharacterBeforeLoneNewlineAfterCarriageReturn() {
Flux<DataBuffer> input = Flux.just(stringBuffer("a\rXY\nb"));
testDecode(input, String.class, step -> step
.expectNext("a\rXY")
.expectNext("b")
.expectComplete()
.verify());
}
@Test
void decodePreservesCharacterAcrossBuffersAfterCarriageReturn() {
Flux<DataBuffer> input = Flux.just(
stringBuffer("a\r"),
stringBuffer("Xb\n")
);
testDecode(input, String.class, step -> step
.expectNext("a\rXb")
.expectComplete()
.verify());
}
@Test
void maxInMemoryLimit() {
Flux<DataBuffer> input = Flux.just(
@@ -1323,6 +1323,48 @@ class DataBufferUtilsTests extends AbstractDataBufferAllocatingTests {
release(foo);
}
@ParameterizedDataBufferAllocatingTest
void matcherDoesNotMatchAcrossNonContiguousDelimiterBytes(DataBufferFactory bufferFactory) {
super.bufferFactory = bufferFactory;
DataBuffer buffer = stringBuffer("a\rXY\nb");
byte[] delims = "\r\n".getBytes(StandardCharsets.UTF_8);
DataBufferUtils.Matcher matcher = DataBufferUtils.matcher(delims);
int result = matcher.match(buffer);
assertThat(result).isEqualTo(-1);
release(buffer);
}
@ParameterizedDataBufferAllocatingTest
void matcherMatchesContiguousTwoByteDelimiter(DataBufferFactory bufferFactory) {
super.bufferFactory = bufferFactory;
DataBuffer buffer = stringBuffer("a\r\nb");
byte[] delims = "\r\n".getBytes(StandardCharsets.UTF_8);
DataBufferUtils.Matcher matcher = DataBufferUtils.matcher(delims);
int result = matcher.match(buffer);
assertThat(result).isEqualTo(2);
release(buffer);
}
@ParameterizedDataBufferAllocatingTest
void matcherDoesNotMatchAfterRepeatedFirstDelimiterByte(DataBufferFactory bufferFactory) {
super.bufferFactory = bufferFactory;
DataBuffer buffer = stringBuffer("a\r\rX\nb");
byte[] delims = "\r\n".getBytes(StandardCharsets.UTF_8);
DataBufferUtils.Matcher matcher = DataBufferUtils.matcher(delims);
int result = matcher.match(buffer);
assertThat(result).isEqualTo(-1);
release(buffer);
}
@ParameterizedDataBufferAllocatingTest
void propagateContextByteChannel(DataBufferFactory bufferFactory) throws IOException {
Path path = Paths.get(this.resource.getURI());