From 15413508a6a05bada5e14763052884469306b4cc Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Sun, 24 May 2026 14:29:39 -0400 Subject: [PATCH] StrBuilder.replaceImpl shrink-branch leaves residual chars in buffer tail StrBuilder.setLength(int) shrink-branch leaves residual chars in buffer tail --- .../org/apache/commons/text/StrBuilder.java | 16 ++++++- .../commons/text/StrBuilderClearTest.java | 45 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/commons/text/StrBuilder.java b/src/main/java/org/apache/commons/text/StrBuilder.java index f29b121519..358b2d9467 100644 --- a/src/main/java/org/apache/commons/text/StrBuilder.java +++ b/src/main/java/org/apache/commons/text/StrBuilder.java @@ -1819,6 +1819,15 @@ public String get() { return toString(); } + /** + * Gets the internal buffer for testing. + * + * @return the internal buffer. + */ + char[] getBuffer() { + return buffer; + } + /** * Copies the character array into the specified array. * @@ -2608,6 +2617,9 @@ private void replaceImpl(final int startIndex, final int endIndex, final int rem if (insertLen != removeLen) { ensureCapacity(newSize); System.arraycopy(buffer, endIndex, buffer, startIndex + insertLen, size - endIndex); + if (size > newSize) { + Arrays.fill(buffer, newSize, size, CharUtils.NUL); + } size = newSize; } if (insertLen > 0) { @@ -2720,12 +2732,12 @@ public StrBuilder setLength(final int length) { throw new StringIndexOutOfBoundsException(length); } if (length < size) { - size = length; + Arrays.fill(buffer, length, size, CharUtils.NUL); } else if (length > size) { ensureCapacity(length); Arrays.fill(buffer, size, length, CharUtils.NUL); - size = length; } + size = length; return this; } diff --git a/src/test/java/org/apache/commons/text/StrBuilderClearTest.java b/src/test/java/org/apache/commons/text/StrBuilderClearTest.java index 4cd901ee26..b7708992bb 100644 --- a/src/test/java/org/apache/commons/text/StrBuilderClearTest.java +++ b/src/test/java/org/apache/commons/text/StrBuilderClearTest.java @@ -17,7 +17,9 @@ package org.apache.commons.text; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -25,6 +27,7 @@ import java.io.Reader; import java.nio.charset.StandardCharsets; +import org.apache.commons.lang3.CharUtils; import org.apache.commons.lang3.SerializationUtils; import org.junit.jupiter.api.Test; @@ -138,6 +141,48 @@ public void testReadFromReaderDoesNotExposeStaleInternalBuffer() throws IOExcept } } + @Test + void testReplaceImplLeavesResidue() throws Exception { + final String string = "SECRET_PASSWORD_DATA"; + final StrBuilder sb = new StrBuilder(string); + assertEquals(20, sb.length()); + // Shrink: replace [0,20) with "X" => removeLen=20, insertLen=1, newSize=1. + sb.replace(0, 20, "X"); + assertEquals(1, sb.length()); + assertEquals("X", sb.toString()); + final char[] buf = sb.getBuffer(); + assertTrue(buf.length >= 20); + // Tail [1..20) should be cleared but isn't on baseline => residue persists. + // Probe offset 5: original was '_' (underscore from "SECRET_..."). After + // arraycopy(buf, endIndex=20, buf, startIndex+insertLen=1, size-endIndex=0) + // the shift is a no-op, so buf[5] retains the original 'T' from "SECRET_". + // Either way it is non-NUL. + assertEquals(CharUtils.NUL, buf[5]); + // Dump the visible residue at the logical-unused tail. + for (int i = 1; i < 20; i++) { + assertEquals(CharUtils.NUL, buf[i]); + } + } + + @Test + void testSetLengthShrinkLeavesResidue() throws Exception { + final String string = "CONFIDENTIAL_TOKEN_VALUE"; + final int len = string.length(); + final StrBuilder sb = new StrBuilder(string); + assertEquals(len, sb.length()); + // setLength(5) shrinks: size = 5, but [5..24) is NOT cleared. + sb.setLength(5); + assertEquals(5, sb.length()); + assertEquals("CONFI", sb.toString()); + final char[] buf = sb.getBuffer(); + assertTrue(buf.length >= len); + // Probe offset 10: original was 'L' (CONFIDENTIA*L*_TOKEN_VALUE). + assertEquals(CharUtils.NUL, buf[10]); + for (int i = 5; i < len; i++) { + assertEquals(CharUtils.NUL, buf[i]); + } + } + @Test public void testStaleCharsNotLeakedAfterClear() throws Exception { final StrBuilder sb = new StrBuilder("secret_password_xyzzy_leak");