From 2cd3d8ca83ff2086a84c089261f95f9c034af10b Mon Sep 17 00:00:00 2001 From: junhyeong9812 Date: Wed, 15 Jul 2026 08:47:54 +0900 Subject: [PATCH] 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 --- .../core/io/buffer/DataBufferUtils.java | 8 ++++ .../core/codec/StringDecoderTests.java | 24 +++++++++++ .../core/io/buffer/DataBufferUtilsTests.java | 42 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java index bf6a56f99f7a..4554f269989e 100644 --- a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java +++ b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java @@ -917,6 +917,14 @@ protected TwoByteMatcher(byte[] delimiter) { 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); + } } diff --git a/spring-core/src/test/java/org/springframework/core/codec/StringDecoderTests.java b/spring-core/src/test/java/org/springframework/core/codec/StringDecoderTests.java index e1a52e7fa483..66e001a5ecdf 100644 --- a/spring-core/src/test/java/org/springframework/core/codec/StringDecoderTests.java +++ b/spring-core/src/test/java/org/springframework/core/codec/StringDecoderTests.java @@ -153,6 +153,30 @@ void decodeNewlinesAcrossBuffers() { .verify()); } + @Test + void decodePreservesCharacterBeforeLoneNewlineAfterCarriageReturn() { + Flux input = Flux.just(stringBuffer("a\rXY\nb")); + + testDecode(input, String.class, step -> step + .expectNext("a\rXY") + .expectNext("b") + .expectComplete() + .verify()); + } + + @Test + void decodePreservesCharacterAcrossBuffersAfterCarriageReturn() { + Flux input = Flux.just( + stringBuffer("a\r"), + stringBuffer("Xb\n") + ); + + testDecode(input, String.class, step -> step + .expectNext("a\rXb") + .expectComplete() + .verify()); + } + @Test void maxInMemoryLimit() { Flux input = Flux.just( diff --git a/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java b/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java index 6c0be595624a..a4a352b4edda 100644 --- a/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java @@ -1323,6 +1323,48 @@ void matcher3(DataBufferFactory bufferFactory) { 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());