Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/Base32.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php declare(strict_types=1);

namespace KDuma\BinaryTools;

use InvalidArgumentException;

/**
* Base32 encoder/decoder using a configurable alphabet (default RFC 4648 without padding).
*/
final class Base32
{
public const DEFAULT_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

/** @var array<string, array<string, int>> */
private static array $decodeMaps = [];

/** @var array<string, bool> */
private static array $validatedAlphabets = [];

public static function toBase32(string $binary, string $alphabet = self::DEFAULT_ALPHABET): string
{
self::ensureValidAlphabet($alphabet);

if ($binary === '') {
return '';
}

$result = '';
$buffer = 0;
$bitsLeft = 0;
$length = strlen($binary);

for ($i = 0; $i < $length; $i++) {
$buffer = ($buffer << 8) | ord($binary[$i]);
$bitsLeft += 8;

while ($bitsLeft >= 5) {
$bitsLeft -= 5;
$index = ($buffer >> $bitsLeft) & 0x1F;
$result .= $alphabet[$index];
}
}

if ($bitsLeft > 0) {
$index = ($buffer << (5 - $bitsLeft)) & 0x1F;
$result .= $alphabet[$index];
}

return $result;
}

public static function fromBase32(string $base32, string $alphabet = self::DEFAULT_ALPHABET): string
{
if ($base32 === '') {
return '';
}

$map = self::decodeMap($alphabet);

$buffer = 0;
$bitsLeft = 0;
$output = '';
$length = strlen($base32);

for ($i = 0; $i < $length; $i++) {
$char = $base32[$i];

if ($char === '=') {
break; // padding reached (RFC 4648)
}

if (!isset($map[$char])) {
throw new InvalidArgumentException(
sprintf(
"Invalid character '%s' at position %d in Base32 input.",
$char,
$i
)
);
}

$buffer = ($buffer << 5) | $map[$char];
$bitsLeft += 5;

if ($bitsLeft >= 8) {
$bitsLeft -= 8;
$output .= chr(($buffer >> $bitsLeft) & 0xFF);
}
}

return $output;
}

/**
* @return array<string, int>
*/
private static function decodeMap(string $alphabet): array
{
if (!isset(self::$decodeMaps[$alphabet])) {
self::ensureValidAlphabet($alphabet);

$map = [];
for ($i = 0; $i < 32; $i++) {
$char = $alphabet[$i];
$map[$char] = $i;
}

self::$decodeMaps[$alphabet] = $map;
}

return self::$decodeMaps[$alphabet];
}

private static function ensureValidAlphabet(string $alphabet): void
{
if (isset(self::$validatedAlphabets[$alphabet])) {
return;
}

if (strlen($alphabet) !== 32) {
throw new InvalidArgumentException('Base32 alphabet must contain exactly 32 characters.');
}

$characters = [];
for ($i = 0; $i < 32; $i++) {
$char = $alphabet[$i];
if (isset($characters[$char])) {
throw new InvalidArgumentException('Base32 alphabet must contain unique characters.');
}
$characters[$char] = true;
}

self::$validatedAlphabets[$alphabet] = true;
}
}
23 changes: 23 additions & 0 deletions src/BinaryString.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ public function toBase64(): string
return base64_encode($this->value);
}

/**
* Returns a Base32-encoded string representation of the binary value.
*
* @param string $alphabet The alphabet to use for Base32 encoding.
* @return string The Base32-encoded string.
*/
public function toBase32(string $alphabet = Base32::DEFAULT_ALPHABET): string
{
return Base32::toBase32($this->value, $alphabet);
}

public function size(): int
{
return strlen($this->value);
Expand All @@ -43,6 +54,18 @@ public static function fromBase64(string $base64): static
return new static(base64_decode($base64, true));
}

/**
* Decodes a Base32-encoded string to a BinaryString instance.
*
* @param string $base32 The Base32-encoded string to decode.
* @param string $alphabet The alphabet used for Base32 encoding. Defaults to Base32::DEFAULT_ALPHABET.
* @return static A new BinaryString instance containing the decoded binary data.
*/
public static function fromBase32(string $base32, string $alphabet = Base32::DEFAULT_ALPHABET): static
{
return new static(Base32::fromBase32($base32, $alphabet));
}

public function equals(BinaryString $other): bool
{
return hash_equals($this->value, $other->value);
Expand Down
138 changes: 138 additions & 0 deletions tests/Base32Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php declare(strict_types=1);

namespace KDuma\BinaryTools\Tests;

use KDuma\BinaryTools\Base32;
use KDuma\BinaryTools\BinaryString;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

#[CoversClass(Base32::class)]
class Base32Test extends TestCase
{
public function testEmpty(): void
{
$this->assertSame('', Base32::toBase32(''));
$this->assertSame('', Base32::fromBase32(''));

$binaryString = BinaryString::fromString('');
$this->assertSame('', $binaryString->toBase32());
$this->assertTrue($binaryString->equals(BinaryString::fromBase32('')));
}

/**
* RFC 4648 test vectors (uppercase, unpadded form).
*
* @return array<string, array{plain: string, base32: string}>
*/
public static function vectors(): array
{
return [
'f' => ['plain' => 'f', 'base32' => 'MY'],
'fo' => ['plain' => 'fo', 'base32' => 'MZXQ'],
'foo' => ['plain' => 'foo', 'base32' => 'MZXW6'],
'foob' => ['plain' => 'foob', 'base32' => 'MZXW6YQ'],
'fooba' => ['plain' => 'fooba', 'base32' => 'MZXW6YTB'],
'foobar' => ['plain' => 'foobar', 'base32' => 'MZXW6YTBOI'],
'A' => ['plain' => 'A', 'base32' => 'IE'],
'AB' => ['plain' => 'AB', 'base32' => 'IFBA'],
'ABC' => ['plain' => 'ABC', 'base32' => 'IFBEG'],
];
}

#[DataProvider('vectors')]
public function testToBase32MatchesKnownVectors(string $plain, string $base32): void
{
$this->assertSame($base32, Base32::toBase32($plain));
$this->assertSame($base32, BinaryString::fromString($plain)->toBase32());
}

#[DataProvider('vectors')]
public function testFromBase32MatchesKnownVectors(string $plain, string $base32): void
{
$this->assertSame($plain, Base32::fromBase32($base32));
$this->assertTrue(BinaryString::fromString($plain)->equals(BinaryString::fromBase32($base32)));
}

public function testRoundTripRandomBinary(): void
{
$lengths = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 16, 31, 32, 33, 64, 100, 256, 1024];

foreach ($lengths as $length) {
$binary = ($length === 0) ? '' : random_bytes($length);
$encoded = Base32::toBase32($binary);
$decoded = Base32::fromBase32($encoded);

$this->assertSame($binary, $decoded, "Failed round-trip at length {$length}");
}
}

public function testDecodeThenEncodeIsIdempotent(): void
{
$original = 'MZXW6YTBOI'; // "foobar"
$decoded = Base32::fromBase32($original);
$reEncoded = Base32::toBase32($decoded);

$this->assertSame($original, $reEncoded);
}

public function testLowercaseInputThrowsException(): void
{
$lower = 'mzxw6ytboi';

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Invalid character 'm' at position 0 in Base32 input.");

Base32::fromBase32($lower);
}

public function testCustomAlphabet(): void
{
$customAlphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUV';
$data = 'Hello World';

$encoded = Base32::toBase32($data, $customAlphabet);
$decoded = Base32::fromBase32($encoded, $customAlphabet);

$this->assertSame($data, $decoded);
}

public function testInvalidAlphabetLengthThrowsException(): void
{
$shortAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12345'; // 31 chars instead of 32

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Base32 alphabet must contain exactly 32 characters.');

Base32::toBase32('test', $shortAlphabet);
}

public function testDuplicateCharactersInAlphabetThrowsException(): void
{
$duplicateAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456A'; // 'A' appears twice

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Base32 alphabet must contain unique characters.');

Base32::toBase32('test', $duplicateAlphabet);
}

public function testPaddingIsIgnoredDuringDecoding(): void
{
$paddedBase32 = 'MZXW6YTBOI======'; // "foobar" with padding
$decoded = Base32::fromBase32($paddedBase32);

$this->assertSame('foobar', $decoded);
}

public function testInvalidCharacterInMiddleThrowsException(): void
{
$invalidBase32 = 'MZXW@YTBOI'; // '@' is invalid

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Invalid character '@' at position 4 in Base32 input.");

Base32::fromBase32($invalidBase32);
}
}
14 changes: 14 additions & 0 deletions tests/BinaryStringTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,18 @@ public function testEquals()
$this->assertTrue($this->binaryString->equals(BinaryString::fromString("\x01\x02\x03\x04")));
$this->assertFalse($this->binaryString->equals(BinaryString::fromString("\xFF\xFF\xFF\xFF")));
}

public function testToBase32()
{
$binaryString = BinaryString::fromString("foobar");
$this->assertEquals("MZXW6YTBOI", $binaryString->toBase32());
}

public function testFromBase32()
{
$reconstructedString = BinaryString::fromBase32("MZXW6YTBOI");
$expected = BinaryString::fromString("foobar");

$this->assertTrue($expected->equals($reconstructedString));
}
}