Skip to content

Commit 15a3a03

Browse files
committed
Merge branch 'main' into codex/refactor-readuint16be-to-readint-with-inttype
2 parents 5713695 + 3b35483 commit 15a3a03

File tree

6 files changed

+313
-3
lines changed

6 files changed

+313
-3
lines changed

src/Base32.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace KDuma\BinaryTools;
4+
5+
use InvalidArgumentException;
6+
7+
/**
8+
* Base32 encoder/decoder using a configurable alphabet (default RFC 4648 without padding).
9+
*/
10+
final class Base32
11+
{
12+
public const DEFAULT_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
13+
14+
/** @var array<string, array<string, int>> */
15+
private static array $decodeMaps = [];
16+
17+
/** @var array<string, bool> */
18+
private static array $validatedAlphabets = [];
19+
20+
public static function toBase32(string $binary, string $alphabet = self::DEFAULT_ALPHABET): string
21+
{
22+
self::ensureValidAlphabet($alphabet);
23+
24+
if ($binary === '') {
25+
return '';
26+
}
27+
28+
$result = '';
29+
$buffer = 0;
30+
$bitsLeft = 0;
31+
$length = strlen($binary);
32+
33+
for ($i = 0; $i < $length; $i++) {
34+
$buffer = ($buffer << 8) | ord($binary[$i]);
35+
$bitsLeft += 8;
36+
37+
while ($bitsLeft >= 5) {
38+
$bitsLeft -= 5;
39+
$index = ($buffer >> $bitsLeft) & 0x1F;
40+
$result .= $alphabet[$index];
41+
}
42+
}
43+
44+
if ($bitsLeft > 0) {
45+
$index = ($buffer << (5 - $bitsLeft)) & 0x1F;
46+
$result .= $alphabet[$index];
47+
}
48+
49+
return $result;
50+
}
51+
52+
public static function fromBase32(string $base32, string $alphabet = self::DEFAULT_ALPHABET): string
53+
{
54+
if ($base32 === '') {
55+
return '';
56+
}
57+
58+
$map = self::decodeMap($alphabet);
59+
60+
$buffer = 0;
61+
$bitsLeft = 0;
62+
$output = '';
63+
$length = strlen($base32);
64+
65+
for ($i = 0; $i < $length; $i++) {
66+
$char = $base32[$i];
67+
68+
if ($char === '=') {
69+
break; // padding reached (RFC 4648)
70+
}
71+
72+
if (!isset($map[$char])) {
73+
throw new InvalidArgumentException(
74+
sprintf(
75+
"Invalid character '%s' at position %d in Base32 input.",
76+
$char,
77+
$i
78+
)
79+
);
80+
}
81+
82+
$buffer = ($buffer << 5) | $map[$char];
83+
$bitsLeft += 5;
84+
85+
if ($bitsLeft >= 8) {
86+
$bitsLeft -= 8;
87+
$output .= chr(($buffer >> $bitsLeft) & 0xFF);
88+
}
89+
}
90+
91+
return $output;
92+
}
93+
94+
/**
95+
* @return array<string, int>
96+
*/
97+
private static function decodeMap(string $alphabet): array
98+
{
99+
if (!isset(self::$decodeMaps[$alphabet])) {
100+
self::ensureValidAlphabet($alphabet);
101+
102+
$map = [];
103+
for ($i = 0; $i < 32; $i++) {
104+
$char = $alphabet[$i];
105+
$map[$char] = $i;
106+
}
107+
108+
self::$decodeMaps[$alphabet] = $map;
109+
}
110+
111+
return self::$decodeMaps[$alphabet];
112+
}
113+
114+
private static function ensureValidAlphabet(string $alphabet): void
115+
{
116+
if (isset(self::$validatedAlphabets[$alphabet])) {
117+
return;
118+
}
119+
120+
if (strlen($alphabet) !== 32) {
121+
throw new InvalidArgumentException('Base32 alphabet must contain exactly 32 characters.');
122+
}
123+
124+
$characters = [];
125+
for ($i = 0; $i < 32; $i++) {
126+
$char = $alphabet[$i];
127+
if (isset($characters[$char])) {
128+
throw new InvalidArgumentException('Base32 alphabet must contain unique characters.');
129+
}
130+
$characters[$char] = true;
131+
}
132+
133+
self::$validatedAlphabets[$alphabet] = true;
134+
}
135+
}

src/BinaryReader.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
use RuntimeException;
66

7-
class BinaryReader
7+
final class BinaryReader
88
{
99
private string $_data;
1010

src/BinaryString.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace KDuma\BinaryTools;
44

5-
readonly class BinaryString
5+
final readonly class BinaryString
66
{
77
protected function __construct(public string $value)
88
{
@@ -23,6 +23,17 @@ public function toBase64(): string
2323
return base64_encode($this->value);
2424
}
2525

26+
/**
27+
* Returns a Base32-encoded string representation of the binary value.
28+
*
29+
* @param string $alphabet The alphabet to use for Base32 encoding.
30+
* @return string The Base32-encoded string.
31+
*/
32+
public function toBase32(string $alphabet = Base32::DEFAULT_ALPHABET): string
33+
{
34+
return Base32::toBase32($this->value, $alphabet);
35+
}
36+
2637
public function size(): int
2738
{
2839
return strlen($this->value);
@@ -43,6 +54,18 @@ public static function fromBase64(string $base64): static
4354
return new static(base64_decode($base64, true));
4455
}
4556

57+
/**
58+
* Decodes a Base32-encoded string to a BinaryString instance.
59+
*
60+
* @param string $base32 The Base32-encoded string to decode.
61+
* @param string $alphabet The alphabet used for Base32 encoding. Defaults to Base32::DEFAULT_ALPHABET.
62+
* @return static A new BinaryString instance containing the decoded binary data.
63+
*/
64+
public static function fromBase32(string $base32, string $alphabet = Base32::DEFAULT_ALPHABET): static
65+
{
66+
return new static(Base32::fromBase32($base32, $alphabet));
67+
}
68+
4669
public function equals(BinaryString $other): bool
4770
{
4871
return hash_equals($this->value, $other->value);

src/BinaryWriter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace KDuma\BinaryTools;
44

5-
class BinaryWriter
5+
final class BinaryWriter
66
{
77
private string $buffer = '';
88

tests/Base32Test.php

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace KDuma\BinaryTools\Tests;
4+
5+
use KDuma\BinaryTools\Base32;
6+
use KDuma\BinaryTools\BinaryString;
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\TestCase;
10+
11+
#[CoversClass(Base32::class)]
12+
class Base32Test extends TestCase
13+
{
14+
public function testEmpty(): void
15+
{
16+
$this->assertSame('', Base32::toBase32(''));
17+
$this->assertSame('', Base32::fromBase32(''));
18+
19+
$binaryString = BinaryString::fromString('');
20+
$this->assertSame('', $binaryString->toBase32());
21+
$this->assertTrue($binaryString->equals(BinaryString::fromBase32('')));
22+
}
23+
24+
/**
25+
* RFC 4648 test vectors (uppercase, unpadded form).
26+
*
27+
* @return array<string, array{plain: string, base32: string}>
28+
*/
29+
public static function vectors(): array
30+
{
31+
return [
32+
'f' => ['plain' => 'f', 'base32' => 'MY'],
33+
'fo' => ['plain' => 'fo', 'base32' => 'MZXQ'],
34+
'foo' => ['plain' => 'foo', 'base32' => 'MZXW6'],
35+
'foob' => ['plain' => 'foob', 'base32' => 'MZXW6YQ'],
36+
'fooba' => ['plain' => 'fooba', 'base32' => 'MZXW6YTB'],
37+
'foobar' => ['plain' => 'foobar', 'base32' => 'MZXW6YTBOI'],
38+
'A' => ['plain' => 'A', 'base32' => 'IE'],
39+
'AB' => ['plain' => 'AB', 'base32' => 'IFBA'],
40+
'ABC' => ['plain' => 'ABC', 'base32' => 'IFBEG'],
41+
];
42+
}
43+
44+
#[DataProvider('vectors')]
45+
public function testToBase32MatchesKnownVectors(string $plain, string $base32): void
46+
{
47+
$this->assertSame($base32, Base32::toBase32($plain));
48+
$this->assertSame($base32, BinaryString::fromString($plain)->toBase32());
49+
}
50+
51+
#[DataProvider('vectors')]
52+
public function testFromBase32MatchesKnownVectors(string $plain, string $base32): void
53+
{
54+
$this->assertSame($plain, Base32::fromBase32($base32));
55+
$this->assertTrue(BinaryString::fromString($plain)->equals(BinaryString::fromBase32($base32)));
56+
}
57+
58+
public function testRoundTripRandomBinary(): void
59+
{
60+
$lengths = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 16, 31, 32, 33, 64, 100, 256, 1024];
61+
62+
foreach ($lengths as $length) {
63+
$binary = ($length === 0) ? '' : random_bytes($length);
64+
$encoded = Base32::toBase32($binary);
65+
$decoded = Base32::fromBase32($encoded);
66+
67+
$this->assertSame($binary, $decoded, "Failed round-trip at length {$length}");
68+
}
69+
}
70+
71+
public function testDecodeThenEncodeIsIdempotent(): void
72+
{
73+
$original = 'MZXW6YTBOI'; // "foobar"
74+
$decoded = Base32::fromBase32($original);
75+
$reEncoded = Base32::toBase32($decoded);
76+
77+
$this->assertSame($original, $reEncoded);
78+
}
79+
80+
public function testLowercaseInputThrowsException(): void
81+
{
82+
$lower = 'mzxw6ytboi';
83+
84+
$this->expectException(\InvalidArgumentException::class);
85+
$this->expectExceptionMessage("Invalid character 'm' at position 0 in Base32 input.");
86+
87+
Base32::fromBase32($lower);
88+
}
89+
90+
public function testCustomAlphabet(): void
91+
{
92+
$customAlphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUV';
93+
$data = 'Hello World';
94+
95+
$encoded = Base32::toBase32($data, $customAlphabet);
96+
$decoded = Base32::fromBase32($encoded, $customAlphabet);
97+
98+
$this->assertSame($data, $decoded);
99+
}
100+
101+
public function testInvalidAlphabetLengthThrowsException(): void
102+
{
103+
$shortAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12345'; // 31 chars instead of 32
104+
105+
$this->expectException(\InvalidArgumentException::class);
106+
$this->expectExceptionMessage('Base32 alphabet must contain exactly 32 characters.');
107+
108+
Base32::toBase32('test', $shortAlphabet);
109+
}
110+
111+
public function testDuplicateCharactersInAlphabetThrowsException(): void
112+
{
113+
$duplicateAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456A'; // 'A' appears twice
114+
115+
$this->expectException(\InvalidArgumentException::class);
116+
$this->expectExceptionMessage('Base32 alphabet must contain unique characters.');
117+
118+
Base32::toBase32('test', $duplicateAlphabet);
119+
}
120+
121+
public function testPaddingIsIgnoredDuringDecoding(): void
122+
{
123+
$paddedBase32 = 'MZXW6YTBOI======'; // "foobar" with padding
124+
$decoded = Base32::fromBase32($paddedBase32);
125+
126+
$this->assertSame('foobar', $decoded);
127+
}
128+
129+
public function testInvalidCharacterInMiddleThrowsException(): void
130+
{
131+
$invalidBase32 = 'MZXW@YTBOI'; // '@' is invalid
132+
133+
$this->expectException(\InvalidArgumentException::class);
134+
$this->expectExceptionMessage("Invalid character '@' at position 4 in Base32 input.");
135+
136+
Base32::fromBase32($invalidBase32);
137+
}
138+
}

tests/BinaryStringTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,18 @@ public function testEquals()
6464
$this->assertTrue($this->binaryString->equals(BinaryString::fromString("\x01\x02\x03\x04")));
6565
$this->assertFalse($this->binaryString->equals(BinaryString::fromString("\xFF\xFF\xFF\xFF")));
6666
}
67+
68+
public function testToBase32()
69+
{
70+
$binaryString = BinaryString::fromString("foobar");
71+
$this->assertEquals("MZXW6YTBOI", $binaryString->toBase32());
72+
}
73+
74+
public function testFromBase32()
75+
{
76+
$reconstructedString = BinaryString::fromBase32("MZXW6YTBOI");
77+
$expected = BinaryString::fromString("foobar");
78+
79+
$this->assertTrue($expected->equals($reconstructedString));
80+
}
6781
}

0 commit comments

Comments
 (0)