This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git


The following commit(s) were added to refs/heads/master by this push:
     new 5ebe419f3 Keep RandomStringUtils.random rejection cap from failing 
valid ranges (#1843)
5ebe419f3 is described below

commit 5ebe419f3bffd4e451bcdf737dfe3d56871af821
Author: Gary Gregory <[email protected]>
AuthorDate: Wed Oct 28 07:15:33 2026 -0400

    Keep RandomStringUtils.random rejection cap from failing valid ranges
    (#1843)
    
    Clarify RandomStringUtils rejection-cap assumptions and strengthen
    tests.
    
    Document the independence, uniformity, and remaining-length assumptions
    behind the probability bound. Add regression tests for sparse code-point
    ranges, counter resets, and all four rejection paths.
---
 src/changes/changes.xml                            |   1 +
 .../apache/commons/lang3/RandomStringUtils.java    |  13 ++-
 .../commons/lang3/RandomStringUtilsTest.java       | 109 ++++++++++++++++++---
 3 files changed, 102 insertions(+), 21 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index b77ce6dc2..748865b51 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -78,6 +78,7 @@ The <action> type attribute can be add,update,fix,remove.
     <action                   type="fix" dev="ggregory" due-to="alhuda, Gary 
Gregory">Restrict LocaleUtils.toLocale to ASCII language, country, and numeric 
area codes; previously accepted non-ASCII codes now cause 
IllegalArgumentException (#1838).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">Deprecate ExceptionUtils.removeCommonFrames(), which is now used only 
by tests. Root-cause stack trace generation compares StackTraceElement objects 
directly.</action>
     <action                   type="fix" dev="ggregory" due-to="alhuda, Gary 
Gregory">Keep DateUtils.ceiling and round inside a DST overlap hour 
(#1842).</action>
+    <action                   type="fix" dev="ggregory" due-to="alhuda, Gary 
Gregory">Keep RandomStringUtils.random rejection cap from failing valid ranges 
(#1843).</action>
     <!-- ADD -->
     <action issue="LANG-1790" type="add" dev="ggregory" due-to="Mert Kirpici, 
Ivan Šarić, Gary Gregory">Add optional FIFO ordering for TimedSemaphore 
blocking acquisitions.</action>
     <!-- UPDATE -->
diff --git a/src/main/java/org/apache/commons/lang3/RandomStringUtils.java 
b/src/main/java/org/apache/commons/lang3/RandomStringUtils.java
index 03ce42d71..6a3d43f01 100644
--- a/src/main/java/org/apache/commons/lang3/RandomStringUtils.java
+++ b/src/main/java/org/apache/commons/lang3/RandomStringUtils.java
@@ -393,13 +393,12 @@ public static String random(int count, int start, int 
end, final boolean letters
         final long desiredCacheSize = ((long) count * gapBits + 
CACHE_PADDING_BITS) / BITS_TO_BYTES_DIVISOR + BASE_CACHE_SIZE_PADDING;
         final int cacheSize = (int) Math.min(desiredCacheSize, 
Integer.MAX_VALUE / BITS_TO_BYTES_DIVISOR + BASE_CACHE_SIZE_PADDING);
         final CachedRandomBits arb = new CachedRandomBits(cacheSize, random);
-        // Bound rejection retries so a range that rejects every sample
-        // (for example, entirely UNASSIGNED/PRIVATE_USE/SURROGATE) raises an
-        // IllegalArgumentException instead of looping indefinitely. Each draw
-        // takes gapBits bits, so a range holding a single acceptable value
-        // accepts a draw with probability 1 / 2^gapBits; scaling the cap by
-        // that sample space keeps a valid request from exhausting it (chance
-        // below e^-64 per character). The counter resets on every accepted 
code point.
+        // Bound consecutive rejected draws so impossible requests eventually 
fail.
+        // Assuming independent uniform draws and at least one value 
acceptable for
+        // the remaining output length, the acceptance probability is at least
+        // 1 / 2^gapBits. A budget of 64 times that sample space makes the 
probability
+        // of exhausting the budget less than exp(-64) per accepted code point.
+        // Reset the counter after each accepted code point.
         final long maxRejections = 64L << gapBits;
         long rejections = 0;
         while (count-- != 0) {
diff --git a/src/test/java/org/apache/commons/lang3/RandomStringUtilsTest.java 
b/src/test/java/org/apache/commons/lang3/RandomStringUtilsTest.java
index 1a9a87089..d342ca0d0 100644
--- a/src/test/java/org/apache/commons/lang3/RandomStringUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/RandomStringUtilsTest.java
@@ -493,20 +493,6 @@ void testOnlyRejectedCodePoints() {
                 () -> assertThrows(IllegalArgumentException.class, () -> 
RandomStringUtils.random(1, 0xE000, 0xF900, false, false, null, new 
Random(42))));
     }
 
-    /**
-     * A range with a single acceptable value is valid and must not trip the 
rejection cap. Here 63 of the 64 chars are rejected by the letters filter and
-     * each draw takes 7 bits, so a draw is accepted with probability 1/128.
-     */
-    @Test
-    void testSparseAcceptableValuesDoNotTripRejectionCap() {
-        final char[] chars = new char[64];
-        Arrays.fill(chars, '-');
-        chars[63] = 'x';
-        for (int seed = 0; seed < 20; seed++) {
-            assertEquals(StringUtils.repeat('x', 100), 
RandomStringUtils.random(100, 0, 64, true, false, chars, new Random(seed)));
-        }
-    }
-
     /**
      * Make sure boundary alpha characters are generated by randomAlphabetic 
This test will fail randomly with probability = 4 * (51/52)**1000 ~ 1.58E-8
      */
@@ -1003,4 +989,99 @@ void testRandomWithChars(final RandomStringUtils rsu) {
         assertNotEquals(r1, r3);
         assertNotEquals(r2, r3);
     }
+
+    /**
+     * Checks that rejected Unicode types and supplementary characters that 
cannot fit in the remaining UTF-16 unit exhaust the retry budget.
+     *
+     * @param codePoint the code point supplied by every draw.
+     */
+    @ParameterizedTest
+    @ValueSource(ints = { 0x0378, 0xD800, 0xE000, 0x10000 })
+    void testRejectedCodePointWithControlledRandom(final int codePoint) {
+        final Random random = new Random(0) {
+            private int bytesGenerated;
+
+            @Override
+            public void nextBytes(final byte[] bytes) {
+                bytesGenerated += bytes.length;
+                assertTrue(bytesGenerated < 1_024, "The rejection cap did not 
terminate generation");
+                Arrays.fill(bytes, (byte) 0);
+            }
+        };
+        // Each draw selects the sole code point: unassigned, surrogate, 
private use, or an assigned supplementary letter.
+        assertThrows(IllegalArgumentException.class, () -> 
RandomStringUtils.random(1, codePoint, codePoint + 1, false, false, null, 
random));
+    }
+
+    /**
+     * Checks that accepting a character resets the consecutive-rejection 
counter.
+     */
+    @Test
+    void testRejectionCapResetsAfterAcceptance() {
+        final char[] chars = new char[255];
+        Arrays.fill(chars, '-');
+        chars[1] = 'x';
+        final Random random = new Random(0) {
+            private int bytesGenerated;
+
+            @Override
+            public void nextBytes(final byte[] bytes) {
+                // Each byte is one draw: reject 10,000 times, then accept, 
and repeat.
+                // Each run is below the 16,384 cap, but their combined length 
exceeds it.
+                for (int i = 0; i < bytes.length; i++) {
+                    bytes[i] = (byte) (++bytesGenerated % 10_001 == 0 ? 1 : 0);
+                }
+            }
+        };
+        assertEquals("xx", RandomStringUtils.random(2, 0, chars.length, true, 
false, chars, random));
+    }
+
+    /**
+     * Checks that both filtered and out-of-range draws exhaust the retry 
budget even when an acceptable value exists.
+     *
+     * @param rejectedValue the value supplied by every draw.
+     */
+    @ParameterizedTest
+    @ValueSource(ints = { 0, 255 })
+    void testRejectionCapWithControlledRandom(final int rejectedValue) {
+        final char[] chars = new char[255];
+        Arrays.fill(chars, '-');
+        chars[1] = 'x';
+        final Random random = new Random(0) {
+            private int bytesGenerated;
+
+            @Override
+            public void nextBytes(final byte[] bytes) {
+                bytesGenerated += bytes.length;
+                // Eight bits per draw; allow cache read-ahead beyond the 
16,384-rejection budget.
+                assertTrue(bytesGenerated < 20_000, "The rejection cap did not 
terminate generation");
+                Arrays.fill(bytes, (byte) rejectedValue);
+            }
+        };
+        assertThrows(IllegalArgumentException.class, () -> 
RandomStringUtils.random(1, 0, chars.length, true, false, chars, random));
+    }
+
+    /**
+     * Checks a sparse code-point range containing 64 private-use characters 
followed by one letter.
+     */
+    @Test
+    void testSparseAcceptableCodePointsDoNotTripRejectionCap() {
+        assertEquals(StringUtils.repeat('\uF900', 100), 
RandomStringUtils.random(100, 0xF8C0, 0xF901, true, false, null, new 
Random(0)));
+    }
+
+    /**
+     * Checks that a sparse character array succeeds for fixed seeds.
+     *
+     * <p>
+     * Here 63 of the 64 chars are rejected by the letters filter and each 
draw takes 7 bits, so a uniform draw is accepted with probability 1/128.
+     * </p>
+     */
+    @Test
+    void testSparseAcceptableValuesDoNotTripRejectionCap() {
+        final char[] chars = new char[64];
+        Arrays.fill(chars, '-');
+        chars[63] = 'x';
+        for (int seed = 0; seed < 20; seed++) {
+            assertEquals(StringUtils.repeat('x', 100), 
RandomStringUtils.random(100, 0, 64, true, false, chars, new Random(seed)));
+        }
+    }
 }

Reply via email to