diff --git a/src/Symfony/Component/String/ByteString.php b/src/Symfony/Component/String/ByteString.php index 8c02e04e7b..2b353570c6 100644 --- a/src/Symfony/Component/String/ByteString.php +++ b/src/Symfony/Component/String/ByteString.php @@ -25,20 +25,64 @@ use Symfony\Component\String\Exception\RuntimeException; */ class ByteString extends AbstractString { + private const ALPHABET_ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + public function __construct(string $string = '') { $this->string = $string; } - public static function fromRandom(int $length = 16): self + /* + * The following method was derived from code of the Hack Standard Library (v4.40 - 2020-05-03) + * + * https://github.com/hhvm/hsl/blob/80a42c02f036f72a42f0415e80d6b847f4bf62d5/src/random/private.php#L16 + * + * Code subject to the MIT license (https://github.com/hhvm/hsl/blob/master/LICENSE). + * + * Copyright (c) 2004-2020, Facebook, Inc. (https://www.facebook.com/) + */ + + public static function fromRandom(int $length = 16, string $alphabet = null): self { - $string = ''; + if ($length <= 0) { + throw new InvalidArgumentException(sprintf('Expected positive length value, got "%d".', $length)); + } - do { - $string .= str_replace(['/', '+', '='], '', base64_encode(random_bytes($length))); - } while (\strlen($string) < $length); + $alphabet = $alphabet ?? self::ALPHABET_ALPHANUMERIC; + $alphabetSize = \strlen($alphabet); + $bits = (int) ceil(log($alphabetSize, 2.0)); + if ($bits <= 0 || $bits > 56) { + throw new InvalidArgumentException('Expected $alphabet\'s length to be in [2^1, 2^56].'); + } - return new static(substr($string, 0, $length)); + $ret = ''; + while ($length > 0) { + $urandomLength = (int) ceil(2 * $length * $bits / 8.0); + $data = random_bytes($urandomLength); + $unpackedData = 0; + $unpackedBits = 0; + for ($i = 0; $i < $urandomLength && $length > 0; ++$i) { + // Unpack 8 bits + $unpackedData = ($unpackedData << 8) | \ord($data[$i]); + $unpackedBits += 8; + + // While we have enough bits to select a character from the alphabet, keep + // consuming the random data + for (; $unpackedBits >= $bits && $length > 0; $unpackedBits -= $bits) { + $index = ($unpackedData & ((1 << $bits) - 1)); + $unpackedData >>= $bits; + // Unfortunately, the alphabet size is not necessarily a power of two. + // Worst case, it is 2^k + 1, which means we need (k+1) bits and we + // have around a 50% chance of missing as k gets larger + if ($index < $alphabetSize) { + $ret .= $alphabet[$index]; + --$length; + } + } + } + } + + return new static($ret); } public function bytesAt(int $offset): array diff --git a/src/Symfony/Component/String/CHANGELOG.md b/src/Symfony/Component/String/CHANGELOG.md index 150c37dd9b..1251fe552e 100644 --- a/src/Symfony/Component/String/CHANGELOG.md +++ b/src/Symfony/Component/String/CHANGELOG.md @@ -12,6 +12,7 @@ CHANGELOG depending of the input string UTF-8 compliancy * added `$cut` parameter to `Symfony\Component\String\AbstractString::truncate()` * added `AbstractString::containsAny()` + * allow passing a string of custom characters to `ByteString::fromRandom()` 5.0.0 ----- diff --git a/src/Symfony/Component/String/Tests/ByteStringTest.php b/src/Symfony/Component/String/Tests/ByteStringTest.php index 28dedb1fb4..da577e0e8a 100644 --- a/src/Symfony/Component/String/Tests/ByteStringTest.php +++ b/src/Symfony/Component/String/Tests/ByteStringTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\String\Tests; use Symfony\Component\String\AbstractString; +use function Symfony\Component\String\b; use Symfony\Component\String\ByteString; class ByteStringTest extends AbstractAsciiTestCase @@ -21,6 +22,47 @@ class ByteStringTest extends AbstractAsciiTestCase return new ByteString($string); } + public function testFromRandom(): void + { + $random = ByteString::fromRandom(32); + + self::assertSame(32, $random->length()); + foreach ($random->chunk() as $char) { + self::assertNotNull(b('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')->indexOf($char)); + } + } + + public function testFromRandomWithSpecificChars(): void + { + $random = ByteString::fromRandom(32, 'abc'); + + self::assertSame(32, $random->length()); + foreach ($random->chunk() as $char) { + self::assertNotNull(b('abc')->indexOf($char)); + } + } + + public function testFromRandomEarlyReturnForZeroLength(): void + { + self::assertSame('', ByteString::fromRandom(0)); + } + + public function testFromRandomThrowsForNegativeLength(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Expected positive length value, got -1'); + + ByteString::fromRandom(-1); + } + + public function testFromRandomAlphabetMin(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Expected $alphabet\'s length to be in [2^1, 2^56]'); + + ByteString::fromRandom(32, 'a'); + } + public static function provideBytesAt(): array { return array_merge(