Skip to content

Commit 64d9080

Browse files
authored
random: Fix off-by-one in fast path selection of Randomizer::getBytesFromString() (#10449)
With a single byte we can choose offsets between 0x00 and 0xff, thus 0x100 different offsets. We only need to use the slow path for sources of more than 0x100 bytes. The previous version was correct with regard to the output expectations, it was just slower than necessary. Better fix this now while we still can before being bound by our BC guarantees with regard to emitted sequences. This also adds a test to verify the behavior: For powers of two we never reject any values during rejection sampling, we just need to mask off the unneeded bits. Thus we can specifically verify that the number of calls to the engine match the expected amount. We also verify that all the possible values are emitted to make sure the masking does not remove any required bits. For inputs longer than 0x100 bytes we need trust the `range()` implementation to be unbiased, but still verify the number of engine calls and perform a basic output check.
1 parent 8f318c3 commit 64d9080

File tree

3 files changed

+122
-1
lines changed

3 files changed

+122
-1
lines changed

ext/random/randomizer.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ PHP_METHOD(Random_Randomizer, getBytesFromString)
401401

402402
retval = zend_string_alloc(length, 0);
403403

404-
if (source_length > 0xFF) {
404+
if (source_length > 0x100) {
405405
while (total_size < length) {
406406
uint64_t offset = randomizer->algo->range(randomizer->status, 0, source_length - 1);
407407

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
--TEST--
2+
Random: Randomizer: getBytesFromString(): Fast Path Masking
3+
--FILE--
4+
<?php
5+
6+
use Random\Engine\Test\TestWrapperEngine;
7+
use Random\Engine\Xoshiro256StarStar;
8+
use Random\Randomizer;
9+
10+
require __DIR__ . "/../../engines.inc";
11+
12+
$allBytes = implode('', array_map(
13+
fn ($byte) => chr($byte),
14+
range(0x00, 0xff)
15+
));
16+
17+
// Xoshiro256** is the fastest engine available.
18+
$xoshiro = new Xoshiro256StarStar();
19+
20+
var_dump(strlen($allBytes));
21+
echo PHP_EOL;
22+
23+
// Fast path: Inputs less than or equal to 256.
24+
for ($i = 1; $i <= strlen($allBytes); $i *= 2) {
25+
echo "{$i}:", PHP_EOL;
26+
27+
$wrapper = new TestWrapperEngine($xoshiro);
28+
$r = new Randomizer($wrapper);
29+
$result = $r->getBytesFromString(substr($allBytes, 0, $i), 20000);
30+
31+
// Xoshiro256** is a 64 Bit engine and thus generates 8 bytes at once.
32+
// For powers of two we expect no rejections and thus exactly
33+
// 20000/8 = 2500 calls to the engine.
34+
var_dump($wrapper->getCount());
35+
36+
$count = [];
37+
for ($j = 0; $j < strlen($result); $j++) {
38+
$b = $result[$j];
39+
$count[ord($b)] ??= 0;
40+
$count[ord($b)]++;
41+
}
42+
43+
// We also expect that each possible value appears at least once, if
44+
// not is is very likely that some bits were erroneously masked away.
45+
var_dump(count($count));
46+
47+
echo PHP_EOL;
48+
}
49+
50+
echo "Slow Path:", PHP_EOL;
51+
52+
$wrapper = new TestWrapperEngine($xoshiro);
53+
$r = new Randomizer($wrapper);
54+
$result = $r->getBytesFromString($allBytes . $allBytes, 20000);
55+
56+
// In the slow path we expect one call per byte, i.e. 20000
57+
var_dump($wrapper->getCount());
58+
59+
$count = [];
60+
for ($j = 0; $j < strlen($result); $j++) {
61+
$b = $result[$j];
62+
$count[ord($b)] ??= 0;
63+
$count[ord($b)]++;
64+
}
65+
66+
// We also expect that each possible value appears at least once, if
67+
// not is is very likely that some bits were erroneously masked away.
68+
var_dump(count($count));
69+
70+
?>
71+
--EXPECT--
72+
int(256)
73+
74+
1:
75+
int(2500)
76+
int(1)
77+
78+
2:
79+
int(2500)
80+
int(2)
81+
82+
4:
83+
int(2500)
84+
int(4)
85+
86+
8:
87+
int(2500)
88+
int(8)
89+
90+
16:
91+
int(2500)
92+
int(16)
93+
94+
32:
95+
int(2500)
96+
int(32)
97+
98+
64:
99+
int(2500)
100+
int(64)
101+
102+
128:
103+
int(2500)
104+
int(128)
105+
106+
256:
107+
int(2500)
108+
int(256)
109+
110+
Slow Path:
111+
int(20000)
112+
int(256)

ext/random/tests/engines.inc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,23 @@ final class TestShaEngine implements Engine
2727

2828
final class TestWrapperEngine implements Engine
2929
{
30+
private int $count = 0;
31+
3032
public function __construct(private readonly Engine $engine)
3133
{
3234
}
3335

3436
public function generate(): string
3537
{
38+
$this->count++;
39+
3640
return $this->engine->generate();
3741
}
42+
43+
public function getCount(): int
44+
{
45+
return $this->count;
46+
}
3847
}
3948

4049
final class TestXoshiro128PlusPlusEngine implements Engine

0 commit comments

Comments
 (0)