diff --git a/src/Base32.php b/src/Base32.php new file mode 100644 index 0000000..fce6233 --- /dev/null +++ b/src/Base32.php @@ -0,0 +1,135 @@ +> */ + private static array $decodeMaps = []; + + /** @var array */ + 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 + */ + 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; + } +} diff --git a/src/BinaryString.php b/src/BinaryString.php index 18e0e7b..da928aa 100644 --- a/src/BinaryString.php +++ b/src/BinaryString.php @@ -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); @@ -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); diff --git a/tests/Base32Test.php b/tests/Base32Test.php new file mode 100644 index 0000000..a37d0b7 --- /dev/null +++ b/tests/Base32Test.php @@ -0,0 +1,138 @@ +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 + */ + 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); + } +} diff --git a/tests/BinaryStringTest.php b/tests/BinaryStringTest.php index 3141092..f18f4cb 100644 --- a/tests/BinaryStringTest.php +++ b/tests/BinaryStringTest.php @@ -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)); + } }