-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathStringGenerator.php
More file actions
111 lines (95 loc) · 3.91 KB
/
StringGenerator.php
File metadata and controls
111 lines (95 loc) · 3.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<?php
declare(strict_types=1);
namespace TaranovEgor\StringGenerator;
/**
* Cryptographically-secure random string generator.
*
* Uses {@see \random_bytes()} for entropy, then extracts characters via
* bit-packing with rejection sampling.
*
* Binary-safe: works correctly with any single-byte alphabet (including
* non-printable characters and the full 0x00–0xFF range).
*
* @see https://github.com/symfony/string/blob/7.4/ByteString.php — original algorithm (MIT)
*/
final class StringGenerator
{
/**
* Generate a random string of the given length.
*
* Accepts either a predefined {@see CharRange} or a raw alphabet string.
* When a string is passed, every distinct byte in it becomes part of the alphabet.
*
* @param positive-int $length
* @param CharRange|string $alphabet Predefined range or custom character set (≥ 2 unique chars)
*
* @throws \InvalidArgumentException If $length < 1 or the alphabet is invalid
* @throws \Random\RandomException If the system cannot provide a source of randomness
*/
public function generate(int $length, CharRange|string $alphabet = CharRange::Alpha): string
{
if ($length < 1) {
throw new \InvalidArgumentException(
\sprintf('Length must be a positive integer, %d given.', $length),
);
}
$chars = $alphabet instanceof CharRange
? $alphabet->alphabet()
: $alphabet;
if ($chars === '') {
throw new \InvalidArgumentException('Alphabet must not be empty.');
}
return $this->randomString($length, $chars);
}
/**
* Core generation algorithm.
*
* Instead of calling {@see \random_int()} N times (N syscalls), we fetch
* a single batch of random bytes and extract characters by consuming
* exactly ⌈log₂(alphabetSize)⌉ bits per attempt.
*
* When the alphabet size is not a power of two, some bit combinations
* map to an index ≥ alphabetSize — those are discarded (rejection sampling).
* The outer loop requests ~2× the expected bytes to guarantee termination
* even in the worst case (alphabet size = 2^k + 1, ~50% rejection rate).
*
* @param positive-int $length
* @param non-empty-string $alphabet
*
* @throws \InvalidArgumentException If the alphabet size is out of supported range
* @throws \Random\RandomException If the system cannot provide a source of randomness
*/
private function randomString(int $length, string $alphabet): string
{
$alphabetSize = \strlen($alphabet);
$bits = (int) \ceil(\log($alphabetSize, 2.0));
if ($bits <= 0 || $bits > 56) {
throw new \InvalidArgumentException('The length of the alphabet must in the [2^1, 2^56] range.');
}
$bitsPerChar = (int) \ceil(\log($alphabetSize, 2.0));
$mask = (1 << $bitsPerChar) - 1;
$result = '';
$remaining = $length;
while ($remaining > 0) {
// Request ~2× the theoretically needed bytes to account for rejection.
$bytesNeeded = \max(1, (int) \ceil(2 * $remaining * $bitsPerChar / 8.0));
$entropy = \random_bytes($bytesNeeded);
$unpackedData = 0;
$unpackedBits = 0;
for ($i = 0; $i < $bytesNeeded && $remaining > 0; $i++) {
$unpackedData = ($unpackedData << 8) | \ord($entropy[$i]);
$unpackedBits += 8;
while ($unpackedBits >= $bitsPerChar && $remaining > 0) {
$index = $unpackedData & $mask;
$unpackedData >>= $bitsPerChar;
$unpackedBits -= $bitsPerChar;
if ($index < $alphabetSize) {
$result .= $alphabet[$index];
--$remaining;
}
}
}
}
return $result;
}
}